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 的核心是一个 value 配 unit 的结构,但实现比想象中讲究。它用 Long 存储纳秒级精度,同时支持 Double 表示的无限精度值。看源码会发现两个内部子类:DurationInNanos 和 DurationDoubleBased,根据数值范围自动选择存储方式。
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,但 Duration 的 toString() 格式在 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.seconds 和 kotlin.time.seconds 同时存在,IDE 自动导入有时会选错。我习惯在文件顶部显式 import kotlin.time.Duration.Companion.seconds,避免 Companion 限定符到处散落。
另一个实际问题是与 Android 框架 API 的互操作。Android 的 Handler.postDelayed 要毫秒 Long,WorkManager 的 setInitialDelay 要 Long 配 TimeUnit,Coroutine 的 delay 直接支持 Duration。混用时会写出一堆转换样板:
// 以前
handler.postDelayed(runnable, delayMs)
// 现在
handler.postDelayed(runnable, delay.inWholeMilliseconds)inWholeMilliseconds 这个属性名我吐槽过,太长。但确实比 toMillis() 更准确——它强调的是"转换为整毫秒,可能截断",提醒你有精度损失。类似的还有 inWholeSeconds、inWholeNanoseconds。如果需要精确值,要用 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,本地测试正常,上线后发现数据异常偏高。
排查后发现,measureTimedValue 的 Duration 结果包含了 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 / 1000 在 DurationDoubleBased 下是 1.0E-9 的近似值,和直接写 1.nanoseconds 的存储方式可能不同。
这个精度问题在音视频同步场景很致命。我们在做音视频 lipsync 校正时,需要把 Duration 换算成采样点数(duration * sampleRate / 1.seconds)。如果 duration 是 DoubleBased,中间结果的浮点误差会导致采样点偏移 1-2 个 frame。
Kotlin 1.9.0 引入了 Duration.toLong(DurationUnit) 和 Duration.toInt(DurationUnit),比 inWholeNanoseconds 更灵活,但截断行为是一样的。我们的 workaround 是:在需要精确整数运算的场景,统一先转成 Long 纳秒,用整数算术算完再转回 Duration。
Android 特定问题:主线程延迟与 `Handler` 的精度
Duration 和 delay() 在协程里配合很好,但和 Android 的 Handler 混用时有行为差异。
// 协程
delay(16.milliseconds) // 精确,基于 Continuation 的调度
// Handler
handler.postDelayed(runnable, 16.milliseconds.inWholeMilliseconds)Handler.postDelayed 的毫秒参数是 Long,Duration 转过来没问题。但 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 支持正无穷和负无穷,用于某些超时语义,但 inWholeNanoseconds 对 INFINITE 会抛异常。
后来改用 toString() 存字符串,但 toString() 的格式在版本间有变化(前面提到的 1.6.20 去掉小数点)。现在稳定下来的格式是 30s、1.5h、100ms 这种,没有 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)这里 * 运算在 Duration 和 Double 之间重载过,返回 Duration。toInt() 是 1.9.0 的 API,之前用 inWholeMilliseconds.toInt() 会溢出(Duration 可能超过 Int 范围)。
但 OkHttp 的 Builder.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.milliseconds 和 1.seconds 可以放进 HashSet 去重。
真正的问题是调试时:两个 Duration 逻辑相等,但 toString() 不同,日志里看起来不一样。我们在一个缓存 key 的场景用了 Duration.toString() 做字符串拼接,结果 1000ms 和 1s 生成不同 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 后基本消除。Duration 的 inline class 优化起了作用,运行时没有对象头开销。
但 TimeSource.Monotonic.markNow() 在 Kotlin 1.7.x 的 Android 实现确实有分配,1.8.0+ 修复。如果项目还在旧版本,高频调用建议直接用 SystemClock.elapsedRealtimeNanos()。
Duration 的字符串解析 parse() 比手写 Long 解析慢一个数量级,配置加载时没问题,但别放在热路径。
什么时候不该用 `Duration`
说了这么多好处,最后说几个我明确不用 Duration 的场景。
和 Android 框架底层交互时,SurfaceFlinger 的 BufferQueue、Camera2 的 CaptureRequest 时间戳,都是裸 Long 纳秒或微秒,加 Duration 转换层纯属多余。
跨进程通信(AIDL)时,Duration 不是 Parcelable,序列化成本高于裸 Long。
需要精确整数运算的音频采样计算,浮点 Duration 的精度损失不可接受,用 Long 纳秒直接算。
这些不是 Duration 设计不好,是它的抽象层级不适合这些场景。好的做法是在配置层、业务逻辑层用 Duration 保证类型安全,在和系统框架的边界处转换回裸类型。
我现在写 Kotlin 代码,默认用 Duration 表达时间概念,只在性能测试确认有瓶颈时才降级。这个习惯是从那个 System.currentTimeMillis() 的线上故障开始的,不是跟风用新 API,是真的被坑过之后的防御性编程。