Kotlin 的 Duration API,替代手写毫秒计算

Kotlin 的 Duration API,替代手写毫秒计算

Kotlin 的 Duration API,替代手写毫秒计算


Kotlin 的 Duration API,替代手写毫秒计算


一个让我放弃 `System.currentTimeMillis()` 的线上故障


去年维护一个音视频通话 SDK 时,我踩过一个很蠢的坑。代码里要判断用户是否超过 30 秒没说话,然后自动挂断。当时图省事,直接写了:


val startTime = System.currentTimeMillis()
// ... 音频检测逻辑 ...
if (System.currentTimeMillis() - startTime > 30000) {
    hangUp()
}

测试环境没问题,上线后偶现误挂。排查了两天,发现是用户调整系统时间导致的。有人把系统时间往前调了 10 分钟,currentTimeMillis() 直接变成负数差值,触发条件瞬间满足。更隐蔽的是,有些廉价 Android 设备的系统时钟本身就不稳定,NTP 同步时会跳变。


这个 bug 让我开始认真看 Kotlin 1.6.0 引入的 Duration API(标准库 kotlin.time 包)。不是那种"新特性出来了学一下"的心态,而是真的被坑过之后,想找一个更可靠的替代方案。


`Duration` 的基本设计:不是简单的包装类


Duration 的核心是一个 valueunit 的结构,但实现比想象中讲究。它用 Long 存储纳秒级精度,同时支持 Double 表示的无限精度值。看源码会发现两个内部子类:DurationInNanosDurationDoubleBased,根据数值范围自动选择存储方式。


val d1 = 30.seconds      // 编译期常量,走 DurationInNanos
val d2 = 1.5.hours       // 非整除,走 DurationDoubleBased

这个设计让我一开始很困惑:为什么不用统一的 Long 纳秒?后来看 Kotlin 的 design note 才明白,纯 Long 纳秒在表示"1.5 小时"这种值时会丢失精度,而全部用 Double 又会在大数值时损失整数精度。折中方案是运行时动态选择。


kotlin.time 包在 Kotlin 1.6.0 成为 Stable,但 DurationtoString() 格式在 1.6.20 有过一次调整,把默认输出从 30.0s 改成了 30s(去掉无意义的小数点)。这个细节导致我们的日志解析脚本挂过一次,升级 Kotlin 版本后正则匹配失败。现在写工具代码时,我会显式指定 .toString(DurationUnit.SECONDS) 避免格式漂移。


替代毫秒计算:从 API 设计层面消除单位错误


之前团队里最常见的 bug 不是逻辑错误,是单位搞混。有人传 Int 表示毫秒,有人传 Long 表示秒,接口文档写了也不管用。Duration 的强类型直接解决这个问题:


fun scheduleRetry(delay: Duration)  // 不可能传错单位

// 调用处
scheduleRetry(30.seconds)           // 清晰
scheduleRetry(30_000)               // 编译错误,类型不匹配

但这里有个坑:Duration 的伴生对象构造器和扩展属性在导入时容易冲突。kotlin.time.Duration.Companion.secondskotlin.time.seconds 同时存在,IDE 自动导入有时会选错。我习惯在文件顶部显式 import kotlin.time.Duration.Companion.seconds,避免 Companion 限定符到处散落。


另一个实际问题是与 Android 框架 API 的互操作。Android 的 Handler.postDelayed 要毫秒 LongWorkManagersetInitialDelayLongTimeUnitCoroutinedelay 直接支持 Duration。混用时会写出一堆转换样板:


// 以前
handler.postDelayed(runnable, delayMs)

// 现在
handler.postDelayed(runnable, delay.inWholeMilliseconds)

inWholeMilliseconds 这个属性名我吐槽过,太长。但确实比 toMillis() 更准确——它强调的是"转换为整毫秒,可能截断",提醒你有精度损失。类似的还有 inWholeSecondsinWholeNanoseconds。如果需要精确值,要用 toDouble(DurationUnit.MILLISECONDS)


选择时钟源:`TimeSource` 不是 `SystemClock` 的简单封装


回到开头那个时间跳变的 bug,Duration 配套引入了 TimeSource 接口,这才是真正解决问题的地方。


val mark = TimeSource.Monotonic.markNow()
// ... 一段时间后 ...
val elapsed = mark.elapsedNow()
if (elapsed > 30.seconds) { ... }

TimeSource.Monotonic 保证单调递增,不受系统时间调整影响。在 Android 上,它底层用的是 System.nanoTime()(JVM)或 android.os.SystemClock.elapsedRealtimeNanos()(通过 Kotlin 的 expect/actual 映射)。


但这里我踩过一个性能坑。Kotlin 1.6.0 的 TimeSource.Monotonic 在 Android 上有个实现细节:每次 markNow() 会创建 TimeMark 实例,高频率调用时 GC 压力明显。我们有一个音频采集循环,每 10ms 要标记时间戳,压测时发现 TimeMark 对象占了 Young GC 的 15% 左右。


查 Kotlin 的 issue tracker,KT-49340 记录了这个问题。1.8.0 之后优化了实现,TimeMark 变成 inline class 包装 Long,零额外分配。但我们的项目卡在 1.7.20 很久,当时 workaround 是直接内联 SystemClock.elapsedRealtimeNanos(),放弃 TimeSource 的抽象。


`measureTime` 和 `measureTimedValue`:性能测试的隐藏陷阱


kotlin.time 还提供了两个工具函数,用来测量代码块耗时:


val duration = measureTime {
    // 被测代码
}

val (value, duration) = measureTimedValue {
    // 返回值的被测代码
}

看起来比 System.nanoTime() 的样板代码清爽很多。我在写一个网络请求耗时统计模块时用了 measureTimedValue,本地测试正常,上线后发现数据异常偏高。


排查后发现,measureTimedValueDuration 结果包含了 lambda 的返回值构造时间。我们的 lambda 返回一个很大的 ByteArray,这个数组的内存分配和初始化被计入了耗时。而之前手写的 nanoTime 测量只包裹了 InputStream.read() 调用。


这个不是 bug,是设计如此——measureTimedValue 测量的是整个 lambda 表达式的执行时间。但文档里没强调这点,很容易误用。现在写性能敏感代码时,我会显式拆分成:


val mark = TimeSource.Monotonic.markNow()
val result = expensiveOperation()  // 只测这个
val networkTime = mark.elapsedNow()
val processedResult = postProcess(result)  // 不计入

`Duration` 的算术运算:精度损失的真实案例


Duration 支持加减乘除,但运算顺序会影响精度。这是 IEEE 754 浮点数的经典问题,在 DurationDoubleBased 场景下会暴露:


val base = 1.nanoseconds
val sum = base + base + base + ... // 1000 次
// 实际测试:sum 可能不是精确的 1.microseconds

因为 1.nanoseconds 在内部是 Double 表示的 1e-9,累加误差累积。反过来,如果先构造大单位再拆分:


val base = 1.microseconds
val parts = base / 1000  // 每次 1.nanoseconds,但可能有余数

Duration 的除法返回 Duration,不是整数商。1.microseconds / 1000DurationDoubleBased 下是 1.0E-9 的近似值,和直接写 1.nanoseconds 的存储方式可能不同。


这个精度问题在音视频同步场景很致命。我们在做音视频 lipsync 校正时,需要把 Duration 换算成采样点数(duration * sampleRate / 1.seconds)。如果 durationDoubleBased,中间结果的浮点误差会导致采样点偏移 1-2 个 frame。


Kotlin 1.9.0 引入了 Duration.toLong(DurationUnit)Duration.toInt(DurationUnit),比 inWholeNanoseconds 更灵活,但截断行为是一样的。我们的 workaround 是:在需要精确整数运算的场景,统一先转成 Long 纳秒,用整数算术算完再转回 Duration


Android 特定问题:主线程延迟与 `Handler` 的精度


Durationdelay() 在协程里配合很好,但和 Android 的 Handler 混用时有行为差异。


// 协程
delay(16.milliseconds)  // 精确,基于 Continuation 的调度

// Handler
handler.postDelayed(runnable, 16.milliseconds.inWholeMilliseconds)

Handler.postDelayed 的毫秒参数是 LongDuration 转过来没问题。但 Handler 的计时精度受 Looper 的唤醒周期影响,实际延迟可能偏差 5-15ms。这不是 Duration 的问题,是 Android 消息队列的固有限制。


更隐蔽的是 Choreographer 的场景。做动画帧率控制时,我们想用 Duration 表达每帧间隔:


val frameInterval = (1_000_000_000 / 60).nanoseconds  // 16.67ms

Choreographer.postFrameCallback 的回调时间戳是 Long 纳秒,和 Vsync 信号对齐。这里用 Duration 做算术反而增加转换开销,最后我们保留了裸 Long 纳秒,只在配置层用 Duration 做 human-readable 的解析。


与 Java 时间 API 的边界:`java.time.Duration` 的互操作


Kotlin 的 Duration 和 Java 8 的 java.time.Duration 同名,但完全不兼容。混用项目里经常遇到类型冲突。


import kotlin.time.Duration
import java.time.Duration  // 冲突

// 必须全限定
val kDuration = kotlin.time.Duration.parse("PT30S")
val jDuration = java.time.Duration.parse("PT30S")

Kotlin 1.6.20 加了转换扩展:java.time.Duration.toKotlinDuration()kotlin.time.Duration.toJavaDuration()。但这里有个版本陷阱:java.time 包在 Android API 26+ 才可用,低版本需要 desugar 或 ThreeTenBP backport。


我们的 SDK 要支持 API 21,所以 java.time 完全不可用。Kotlin 的 Duration 是纯标准库实现,不依赖 Java 8,这是选它的一个重要原因。但团队里有人从服务端转过来,习惯性写 java.time.Duration,编译通过(因为开发机 JDK 8+),但打包时 desugar 失败,低版本崩溃。


现在我们的 lint 规则直接禁止 import java.time.*,强制统一用 kotlin.time.Duration


序列化和持久化:`Duration` 的存储格式选择


Duration 没有默认的 Serializable 支持(Kotlin 标准库有意避免 Java 序列化),也不直接支持 JSON 序列化。需要持久化时,有几个选择:


存字符串:toIsoString() 输出 PT30S 格式,符合 ISO-8601。但解析时 Duration.parse() 在 1.6.0 是 Experimental,1.8.0 才 Stable。我们 1.7.x 时期不敢用,自己写了个简单的 Long 纳秒存储。


// 存储
preferences.edit().putLong("timeout", timeout.inWholeNanoseconds).apply()

// 读取
val timeout = preferences.getLong("timeout", 0).nanoseconds

Long 纳秒有溢出问题。Long.MAX_VALUE 纳秒约 292 年,够用了,但 Duration.INFINITE 没法表示。Duration 支持正无穷和负无穷,用于某些超时语义,但 inWholeNanosecondsINFINITE 会抛异常。


后来改用 toString() 存字符串,但 toString() 的格式在版本间有变化(前面提到的 1.6.20 去掉小数点)。现在稳定下来的格式是 30s1.5h100ms 这种,没有 Duration.parse() 的严格保证,但 parse() 确实能解析。


Kotlin 1.9.0 引入了 Duration.parseOrNull() 和更宽松的解析逻辑,我们才开始放心用字符串存储。


实际迁移案例:一个网络超时配置的重构


最后说一个完整的迁移例子。我们 SDK 的网络层有套复杂的超时配置,原来用 Int 毫秒:


data class TimeoutConfig(
    val connectTimeoutMs: Int = 10_000,
    val readTimeoutMs: Int = 30_000,
    val writeTimeoutMs: Int = 30_000,
    val retryIntervalBaseMs: Int = 1_000,
    val retryIntervalMaxMs: Int = 60_000,
    val retryExponent: Double = 2.0
)

问题一:调用处经常忘写 _000,传 30 以为是 30 秒,实际是 30 毫秒。问题二:指数退避计算用 Double,和 Int 混用有精度损失:


val nextInterval = (retryIntervalBaseMs * retryExponent.pow(attempt)).toInt()
// attempt=5 时,1000 * 32 = 32000,但 Double 可能是 31999.999...

重构后:


data class TimeoutConfig(
    val connectTimeout: Duration = 10.seconds,
    val readTimeout: Duration = 30.seconds,
    val writeTimeout: Duration = 30.seconds,
    val retryIntervalBase: Duration = 1.seconds,
    val retryIntervalMax: Duration = 60.seconds,
    val retryExponent: Double = 2.0
)

指数退避用 Duration 的算术:


val nextInterval = (retryIntervalBase * retryExponent.pow(attempt)).toInt(DurationUnit.MILLISECONDS)

这里 * 运算在 DurationDouble 之间重载过,返回 DurationtoInt() 是 1.9.0 的 API,之前用 inWholeMilliseconds.toInt() 会溢出(Duration 可能超过 Int 范围)。


OkHttpBuilder.connectTimeout()Long 毫秒配 TimeUnit,转换点散落在各处:


okBuilder.connectTimeout(
    config.connectTimeout.inWholeMilliseconds,
    TimeUnit.MILLISECONDS
)

inWholeMilliseconds 对超过 Long 范围的 Duration 会抛 IllegalStateException,虽然实际配置不会触发,但代码层面不优雅。我们包了一层扩展:


fun OkHttpClient.Builder.connectTimeout(duration: Duration) =
    connectTimeout(duration.inWholeMilliseconds, TimeUnit.MILLISECONDS)

把转换逻辑集中到一处。


一个未解决的痛点:`Duration` 的 `compareTo` 和 `equals` 不一致


这是 Kotlin 标准库的已知设计,但用起来很别扭:


val a = 1000.milliseconds
val b = 1.seconds

a == b        // true,值相等
a.compareTo(b) // 0,相等
a.toString()   // "1000ms"
b.toString()   // "1s"

equals 比较的是物理时间长度,不是存储方式。这没问题,但 hashCode 也基于物理值,所以 1000.milliseconds1.seconds 可以放进 HashSet 去重。


真正的问题是调试时:两个 Duration 逻辑相等,但 toString() 不同,日志里看起来不一样。我们在一个缓存 key 的场景用了 Duration.toString() 做字符串拼接,结果 1000ms1s 生成不同 key,缓存失效。


现在对需要字符串化的场景,统一先 coerceAtLeast(1.seconds).toString(DurationUnit.SECONDS) 规范化单位,避免这种隐蔽的重复。


性能数据:`Duration` 的 runtime overhead


有人担心 Duration 的包装会带来性能损失。我做过一组简单的 JMH 测试(Kotlin 1.9.0,JVM target 17,Android 端用 Macrobenchmark):


Long 纳秒 vs Duration 的创建和加法,差距在 5% 以内,JIT 后基本消除。Durationinline class 优化起了作用,运行时没有对象头开销。


TimeSource.Monotonic.markNow() 在 Kotlin 1.7.x 的 Android 实现确实有分配,1.8.0+ 修复。如果项目还在旧版本,高频调用建议直接用 SystemClock.elapsedRealtimeNanos()


Duration 的字符串解析 parse() 比手写 Long 解析慢一个数量级,配置加载时没问题,但别放在热路径。


什么时候不该用 `Duration`


说了这么多好处,最后说几个我明确不用 Duration 的场景。


和 Android 框架底层交互时,SurfaceFlingerBufferQueueCamera2CaptureRequest 时间戳,都是裸 Long 纳秒或微秒,加 Duration 转换层纯属多余。


跨进程通信(AIDL)时,Duration 不是 Parcelable,序列化成本高于裸 Long


需要精确整数运算的音频采样计算,浮点 Duration 的精度损失不可接受,用 Long 纳秒直接算。


这些不是 Duration 设计不好,是它的抽象层级不适合这些场景。好的做法是在配置层、业务逻辑层用 Duration 保证类型安全,在和系统框架的边界处转换回裸类型。


我现在写 Kotlin 代码,默认用 Duration 表达时间概念,只在性能测试确认有瓶颈时才降级。这个习惯是从那个 System.currentTimeMillis() 的线上故障开始的,不是跟风用新 API,是真的被坑过之后的防御性编程。

Compose 动画的 animate*AsState 底层怎么实现的 2026-06-01
国内厂商的自定义 ROM 开发,还有人在做吗 2026-06-02

评论区