把点划钉在纳秒上:FlashMorse 的时序引擎与 Miuix 界面工程
上一篇《闪光摩斯:让手电筒替你说话》讲了这个 App 想解决什么问题。这一篇拆工程实现:一条从文本到光的链路,以及为了让「点划比例」在任何设备上都站得住,踩过的那些坑。 所有结论都来自源码与真机实测,不含推测。源码以 MIT 许可开源在 Fq77/FlashMorse,下面引用的每一段代码都可以在仓…

目录(展开)
上一篇《闪光摩斯:让手电筒替你说话》讲了这个 App 想解决什么问题。这一篇拆工程实现:一条从文本到光的链路,以及为了让「点划比例」在任何设备上都站得住,踩过的那些坑。
所有结论都来自源码与真机实测,不含推测。源码以 MIT 许可开源在 Fq77/FlashMorse,下面引用的每一段代码都可以在仓库里找到。
数据流总览
flowchart TD
A[输入框文本] --> B[TextNormalizer<br/>全角转半角 / 中文标点映射 / 非法字符替换]
B --> C[MorseTable<br/>ITU-R M.1677-1]
C --> D[MorseCode<br/>词 / 字母 / 符号三层]
D --> E[ScriptCompiler<br/>编译为绝对时间轴 SendScript]
E --> F[MorseTransmitter<br/>HandlerThread 调度]
F --> G{通道}
G --> H[LED: CameraManager]
G --> I[屏幕: drawBehind 整屏白黑]
F --> J[侧音: AudioTrack 600 Hz]
关键分层意图:core:morse 是纯 Kotlin JVM 模块,零 Android 依赖。整条「文本 → 时间轴」的链路可以在 JVM 上跑单测,不需要模拟器;Android 的 API 只出现在 core:torch 之后。
一、绝不使用「逐条累积 delay」
摩斯电码的时序标准是 ITU-R M.1677-1:点 1t、划 3t、元素间隔 1t、字符间隔 3t、词间隔 7t,单位时长 t = 1200 / WPM 毫秒(PARIS 基准)。10 WPM 就是 t = 120 ms、点 120 ms、划 360 ms、词间隔 840 ms。
最常见的实现是这样:
// 反例:不要这么写
for (symbol in symbols) {
light.on()
delay(symbol.duration)
light.off()
delay(gap)
}
问题不在精度,而在误差会累积。每一次 delay 的实际唤醒都比请求晚一点(调度粒度、GC、主线程排队),120 个符号之后偏差能到几百毫秒;接收端读到的是「点越来越长」,也就是速度在漂移。更糟的是漂移不可复现——同一段报文两次发送的长度都不一样。
所以这里的第一层设计是:编译期把时间轴算成绝对偏移。
fun compile(code: MorseCode, timing: TimingProfile): SendScript {
var elapsedNanos = 0L
var state = false
for (segment in segments) {
val durationNanos = segment.durationMillis * NANOS_PER_MILLI
cells += ScriptCell(
index = cells.size,
symbol = segment.symbol,
startNanos = elapsedNanos,
endNanos = elapsedNanos + durationNanos,
)
if (segment.on != state) { // 只在真正翻转时记一个事件
events += ScriptEvent(offsetNanos = elapsedNanos, on = segment.on, cellIndex = cells.size - 1)
state = segment.on
}
elapsedNanos += durationNanos
}
// 脚本必须以熄灯结束
if (state) events += ScriptEvent(offsetNanos = elapsedNanos, on = false, cellIndex = cells.lastIndex)
return SendScript(code, timing, events, cells, totalNanos = elapsedNanos)
}
SendScript 同时保留两套视图,各自服务一类需求:
events:只含通断翻转的稀疏序列,调度器只消费它。SOS 这种全是短元素、连续同态间隔的报文,事件数远少于段数。cells:每个符号占一格的连续时间轴,UI 的进度环、当前字符、剩余时间都从它派生。
一次编译,两个消费者。UI 不需要知道调度细节,调度器也不需要理解「字符」。
二、调度线程:粗睡 + 自旋
调度跑在独立的 HandlerThread 上,优先级取 THREAD_PRIORITY_URGENT_AUDIO(-19)。不占协程调度器,也不和 UI 抢主线程。
等待策略是混合的,因为两种纯方案都不成立:
- 纯
LockSupport.parkNanos():Linux 调度器通常会让它提前返回几十到几百微秒,误差不可控; - 纯自旋:能把误差压到亚毫秒,但一个 840 ms 的词间隔会白烧 840 ms 的 CPU。
private fun waitUntil(targetNanos: Long, token: Int) {
while (runToken == token) {
val remaining = targetNanos - clock.nanos()
if (remaining <= 0L) return
if (remaining > COARSE_THRESHOLD_NANOS) { // 4 ms
LockSupport.parkNanos(remaining - COARSE_MARGIN_NANOS) // 提前 2 ms 醒
} else {
Thread.yield()
}
}
}
粗睡刻意留 2 ms 余量,最后 4 ms 用 yield 的忙等收口。真机上用 logcat 记录「目标时刻 − 实际下发时刻」,逐事件 late = 0(毫秒精度下无落后),点划比例实测 3.0。
时钟源用 SystemClock.elapsedRealtimeNanos(),不用 System.currentTimeMillis()——后者会被 NTP 校时和用户改时间影响,一次对时就足以让整条时间轴跳变。
三、L_cmd:把「下指令」和「出光」分开
即使调度器完全准时,光也不会在指令那一刻出现。CameraManager.setTorchMode 要走一次 binder 到相机 HAL,实测延迟在一二十毫秒量级。这段延迟叫 L_cmd(command latency)。
天真的修法是「每次提前 L 下指令」,但那会让每个点的实际发光时长变成 duration + L;如果 L 随温度、负载波动,点划比例就跟着抖。
这里的做法是整体平移锚点:
// 锚点后移 L:光在 (锚点 + offset) 出现,逻辑时间轴与实际发光对齐
var anchorNanos = clock.nanos() + compensationNanos
...
val targetNanos = anchorNanos + event.offsetNanos - compensationNanos
waitUntil(targetNanos, token)
controller.setOn(event.on)
把 anchor 后移 L,再在计算目标时刻时减掉 L,得到的效果是:所有指令统一下发早 L,但每一段的时长完全不变。于是:
- 点划比例严格保持 3:1,不受 L 的实际值影响;
- L 的估计误差只体现为整段报文的绝对起点偏移,不体现为内部失真——对接收方而言,起点早或晚几百毫秒毫无意义。
补偿值可以在设置里的「时序校准」子页实测调整,默认 0。
循环发送(SOS)还有一个额外处理:一轮结束后补一个词间隔,然后重新取锚点,而不是沿用上一轮的锚点累加。长时间循环下这是唯一能保证误差不跨轮次传递的方式。
四、屏幕通道:两次「乱闪」的真实根因
屏幕通道的语义很简单:亮时整屏白、灭时整屏黑,中间不留任何常亮元素。但第一版在真机上出现了明显的乱闪,根因有两个,都不是「帧率不够」这么笼统。
根因一:灯的亮灭被当成组合期状态
最初 lit 是一个 State<Boolean>,在装配层(页面组合期)被读取并传下去。结果是每一次通断都触发整棵页面树重组——重组一次几十毫秒,主线程一掉帧,灯的边沿就错位,观感就是「闪得没有节奏」。
修法是把读取时机推迟到绘制阶段:
Box(
modifier = modifier
.fillMaxSize()
.clickable(indication = null, interactionSource = source, onClick = onStop)
.drawBehind {
// 在绘制阶段读快照状态:变化只触发重绘,组合与布局完全不参与
drawRect(if (litState.value) Color.White else Color.Black)
},
)
drawBehind 里的读取只订阅重绘,不触发组合与布局。一次通断的成本从「重组整棵树」降到「重绘一个矩形」。
根因二:22 WPM 下点在 60 Hz 屏上只有 3 帧
第二个根因更有意思:设备上残留的 WPM 被写成了 22,t = 55 ms。60 Hz 屏幕的帧间隔是 16.7 ms,一个点只有 3 帧出头。视觉上这是「频闪」,不是「点」。
按 10 WPM(t = 120 ms)逐帧核对:
| 元素 | 理论时长 | 60 Hz 帧数 | 实测 |
|---|---|---|---|
| 点 | 120 ms | 7.2 | 7–8 帧 |
| 划 | 360 ms | 21.6 | 21–22 帧 |
| 词间隔 | 840 ms | 50.4 | 50 帧 |
| 边沿误差 | — | ±1 帧 | 符合 |
结论很清楚:引擎没有漂移,帧预算才是屏幕通道的下限。这也反过来验证了 LED 的 15 WPM 阈值设置——不是保守,是两条通道的物理限制不同:屏幕受渲染帧率约束,LED 受 HAL 调度延迟约束。
屏幕通道还有两个刻意的约束:不参与亮度调节(它的价值就是「最亮」);点击任意位置即停止(全屏输出时用户看不到底栏,必须就地给出停止入口)。
五、侧音:AudioTrack 的两个陷阱
发报时可选 600 Hz 滴声(短波监听的传统侧音频率),灯亮即响。这段代码看起来最无害,实际是「屏幕乱闪」的元凶之一。
- 音轨必须预创建。 最初在时序线程首次发声时才
new AudioTrack+write,耗时数十毫秒。waitUntil的目标时刻早就过去了,后面的所有事件开始连续追赶——表现就是「屏幕一直快速闪,滴声跟着错」。 - 亮灭切换用
setVolume,不用pause/play。 后两者在部分设备上有可感知延迟,同样会拖慢时序线程;音量是即时的混音参数更新。
// MODE_STATIC + 无限循环:轨道常驻播放,「灭」只是把音量调到 0
AudioTrack.Builder()
.setTransferMode(AudioTrack.MODE_STATIC)
.setBufferSizeInBytes(frameCount * 2)
.build()
.apply {
write(samples, 0, samples.size)
setLoopPoints(0, frameCount, -1) // -1 = 无限循环
}
音轨在 MorseTransmitter 构造时就创建,设置里关掉声音只是让它静音,轨道不销毁。代价是常驻一小块音频缓冲,换来的是时序线程上零分配。
六、手电筒:零权限,但要自己记账
setTorchMode 不返回状态,也不回读状态,所以「灯现在到底亮没亮」必须应用自己维护。这里用 requestedOn 记录期望值,并监听 CameraManager.TorchCallback 区分两种情况:自己关灯,和被其他应用抢占(比如用户切到相机)。后者会发一个 externalTakeover 事件,UI 据此提示。
亮度调档(API 33+)踩到两个真 bug:
minStrengthLevel不能用默认档。 默认档是setTorchMode(true)点亮时使用的档位,不等于最暗档。某机型默认档恰好等于最高档,滑杆范围被压成 0,表现为「怎么滑都不变」。正确下界是 1。- 点亮必须带档位。
setTorchMode(true)按默认档点亮,会把用户设定的亮度直接冲掉。所以开灯一律走turnOnTorchWithStrengthLevel(id, level),关灯仍走setTorchMode(false)(灭灯路径越短越好)。该接口一次调用失败就永久置supportsBrightness = false并降级——否则每个符号都会构造一次异常,时序线程会被拖垮。
档位到亮度的映射做了 γ = 2.2 的感知补偿:
val curved = Math.pow(normalized.toDouble(), GAMMA.toDouble())
return (minLevel + span * curved).toInt().coerceIn(minLevel, maxLevel)
硬件档位近似线性对应驱动电流,人眼却是幂律响应;不做补偿,滑杆低段「一滑就暗到底」、高段几乎没变化。端点严格对齐(0 → 1、1 → max),保证「最暗」和「最亮」能被滑到。亮度下限设在 10%:再低的话 LED 边沿明显变软,已经不适合当信号用。
七、UI 工程:三个和 Compose 有关的硬坑
1. 导航 chrome 必须下沉到每个页面
第一版把 Scaffold(顶栏 + 底栏)放在导航宿主外层,NavDisplay 只负责内容区。结果手势返回时,正文在位移、顶栏纹丝不动、底栏要等 atRoot 变 true 才更新——观感是「页面在动,壳没动」。
修法是新增 AppPages.kt,把顶栏/底栏下沉到每个路由自己的 Scaffold 里(HomePage / DetailPage)。chrome 与正文同属一个 entry,共享同一个 graphicsLayer,位移天然同步。
2. Pager 与 TAB 的双向同步竞态
底部导航用 HorizontalPager,点击 TAB 和左右滑动都要能切页。双向绑定的第一版会「点一下没反应,稍等才能再点」。根因链是这样的:
currentPage 在 settle 过程中就跳到最近邻 → snapshotFlow 回写当前 TAB → LaunchedEffect(tab) 反向发起动画 → 两个动画互相拉扯,卡在两页之间。
两条修法各管一边:
- 滑动 → TAB:改读
settledPage(动画完全落定才更新),并用rememberUpdatedState防止长驻协程捕获过期值。 - TAB → Pager:点击时提升
PagerState到调用方,直接scope.launch { animateScrollToPage(n) }。同优先级的 mutate 会当场打断旧动画,连续改向时没有落定间隙。
顺带一个性能项:beyondViewportPageCount = 2。总共三页,让它们常驻组合后,切页只剩纯位移动画;gfxinfo 实测 7 次切页掉帧率 0.92%、p95 = 15 ms。
3. 水平手势要在 Initial pass 抢
发报页的摩斯流预览可以横向拖动,但一开始怎么都不生效——horizontalScroll、detectHorizontalDragGestures 都输给了外层 HorizontalPager。Pager 在 Main pass 就消费了水平拖动,表现为「想拖预览,结果切了页」。
解法是在 Initial pass 拦截:
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
// 逐事件在 Initial pass 消费,带垂直意图逃逸(dy 超阈值就放手让 Pager 接管)
}
Initial pass 自上而下传递,比 Pager 更早拿到事件;加一条垂直意图逃逸,才能保证上下滑动仍然能切页。
八、怎么验证:不要相信截图
这个项目的几个关键结论,最后都不是靠肉眼确认的:
- 动画时长:用
dumpsys gfxinfo统计帧序列(3000 ms 版本 4 秒内 590 帧、janky 0.17%),而不是adb screencap反推——注入和截屏各有一两百毫秒延迟,反推出的时长毫无意义。 - 按钮等高:
ffmpeg裁切 → rawvideo → python 逐像素扫描色段边界,实测 SOS 按钮 153 px vs 蓝色按钮 189 px,修复后统一 189 px。目测永远看不出来。 - 控件坐标:
uiautomator dump输出的 bounds 才是真坐标。曾用缩放过的截图按比例换算,点在按钮间隙上,误判「编辑按钮无响应」。 - 长按拖拽:
adb input swipe测不了(位移立即超过 touch slop,长按被取消),必须手工拼motionevent DOWN → 停 0.6 s → MOVE序列。 - 时序一致性:
core:morse的 19 个单测覆盖 ITU 码表逐字比对、SOS 总长 = 27t = 3240 ms、点划比 3.0、事件序列单调且无漂移;core:model另有 7 个亮度换算测试(其中一条抓到0.29f * 100 = 28.999被toInt截断成 28% 的真 bug)。
一个更实际的教训:真机验证期间设备上是有真人在用的。一次「手电筒莫名退出」排查了很久,最后发现是前台被切到了别的应用。之后所有 adb 验证前先确认 topResumed 是自己。
九、欠账与取舍
- 没有前台服务。 发报是一次几十秒的交互,用户全程盯屏,Service 带来的常驻通知与权限复杂度不划算。
- 没有浅色主题。 代码成本很低(除
ScreenFlashOverlay两处硬编码,其余全部走colorScheme),真正的工作量在二次标定:发送卡主色叠加 12%/42%、符号流待发色 28% 都必须在浅色下重算。而且夜间发报是主场景,从亮界面切到全屏黑底的那次跳变容易被误读成「信号已开始」。 - 没有 Room / Hilt。 数据量极小(≤50 条历史、数十条短语),DataStore + JSON 文件足够;手工
AppContainer做依赖装配,避免注解处理器进入构建链路。 - Camera2 降级路径未实现。
setTorchMode在 API 23+ 全覆盖,Camera2 只对需要 CAMERA 权限的极端机型有意义,暂不做。
最后
摩斯电码的时序标准写于上世纪,但要在现代 Android 上忠实执行它,难点全在别处:线程优先级、音频轨道的创建时机、渲染帧预算、相机 HAL 的延迟、以及 Compose 的重组边界。
一句话总结这个项目的工程观:能被量化的,就不要靠感觉;能被单测覆盖的,就不要靠真机碰运气。 光要么准时,要么就是另一段话。
源码:Fq77/FlashMorse,MIT 许可。项目不提供预编译 APK,按 README 自行构建;本文涉及的全部模块与单测都可以在本地复现。