MainActivity EDF 写入与断点续写全链路解析
目标与范围
本文把 MainActivity.kt 与 app 内 EDF 工具链放在一起看,给出:
- 原生入口到 EDF 文件字节改动的完整路径
- 哪一段代码负责哪一类 EDF 改动(头、样本区、注释区)
- 断点续写时“修复 + 继续写 + 回放标签 + 补洞”的精确过程
- 与 Dart 侧恢复逻辑(会话文件、补零)的联动
为避免歧义,文中“行号”均指当前仓库对应文件中的真实行号。
EDF 文件底层(本项目依赖实现)
EDFwriter.java 顶部注释明确了标准头关键偏移(ASCII 定长字段):
offset 168, len 8: 开始日期dd.mm.yyoffset 176, len 8: 开始时间hh.mm.ssoffset 184, len 8: 头长度(字节)offset 236, len 8: 数据记录数offset 244, len 8: 每条数据记录时长(秒)offset 252, len 4: 信号数
本项目写的是 EDF+(f_filetype = 0),每条样本是 16-bit little-endian,注释通道也占每个 data record 的固定字节(EDFLIB_ANNOTATION_BYTES = 114,默认 1 个注释通道)。
一、调用入口:Flutter -> Android
MainActivity.kt 的 configureFlutterEngine 里 MethodChannel 暴露了核心动作:
createEdfFile(L391-L399)repairAndResumeEdfFile(L401-L409)writeEdfData(L411-L416)setSignalLabel/baselineStart/baselineEnd(L418-L440)close(L448-L451)
对应 Dart 包装在 lib/utils/EdfManager.dart:
- 正常创建:
createEdfFile() - 崩溃恢复:
repairAndResumeEdfFile() - 连续写数据:
writeEdfData(...) - 结束:
close()
二、建文件阶段:头参数如何决定二进制布局
1) 创建 writer 与基础状态
MainActivity.kt createEdfFile(...)(L711-L722):
- 记录开始时间与标签 sidecar 初始化(L713-L714)
file_out = EDFwriter(path, filetype, signals)(L717)configureEdfWriter(...)(L718)
2) 信号参数配置(决定每个 record 的字节结构)
configureEdfWriter(...)(L724-L752)对 5 通道做配置:
- 通道 0/1:采样率 500Hz,数字范围 -32768~32767,单位
uV - 通道 2/3/4:采样率 50Hz,数字范围 -8192~8191,单位
mG - 所有通道物理范围统一 ±4000
- 写入 label:
Channel1/Channel2/X/Y/Z
这会直接影响 EDFwriter.write_edf_header() 里:
param_smp_per_record[](每 record 每通道样本数)param_phys_* / param_dig_*(物理量到数字量映射)recordsize计算(L1788-L1836)
按当前配置,单个 data record(默认 1 秒)大小为:
- EEG 两通道:
(500 + 500) * 2 = 2000 bytes - 三轴三通道:
(50 + 50 + 50) * 2 = 300 bytes - 注释通道:
114 bytes - 合计:
2414 bytes/record
3) 头真正落盘时机
write_edf_header() 不是创建时立即写满,而是首次样本写入时触发:
writePhysicalSamples()(L1469 起)里if (datarecords == 0 && edfsignal == 0) write_edf_header();
因此,文件第一次写样本前,参数还能继续改;一旦首个 signal 写入,头就固定并写到文件前部(seek(0) 后顺序输出)。
三、实时写入阶段:每次调用具体改了哪里
3.1 队列与串行化
MainActivity.kt 用单线程执行器 + 队列保证写入顺序:
- 采集数据进
writeEdfData(...)(L97-L107) - 文件未创建前进入
pendingWriteQueue,创建后 flush(L109-L119) - 实际写盘在
writeEdfDataSync(...)(L816-L840)
3.2 writeEdfDataSync 的关键动作
以一次 type == 0 调用为例(L823-L833):
mNum++:逻辑上将“秒计数/记录计数”向前推进 1List<Double>拷贝到DoubleArrayoutputFile.writePhysicalSamples(buf):交给 EDFwriter 执行物理值->数字值转换并写字节
writePhysicalSamples()(EDFwriter.java)内部行为:
- 根据当前
signal_write_sequence_pos判断这是哪个通道的数据 - 按该通道
sampleFrequency读取前sf个样本 - 用
param_bitvalue/param_offset转成整数 ADC 值 - 按 little-endian 写入 2 字节/样本
signal_write_sequence_pos++- 如果一个 data record 的所有信号都写完:
- 写 TAL 时间锚(
write_tal()) datarecords++
- 写 TAL 时间锚(
这意味着单次 writePhysicalSamples() 只推进一个信号位,不一定立即形成完整 record;只有凑满全部信号顺序后,datarecords 才递增。
3.3 注释写入与 sidecar
setSignalLabel()(L843-L861)与 setBaseline()(L863-L878)做两件事:
- 立即写 sidecar:
<edf>.labels.jsonl(L926-L938) - 调用
file_out.writeAnnotation(onset, -1, label)加入内存注释列表
注意:writeAnnotation()(EDFwriter.java L1699 起)只是把注释对象放入 annotationslist,并不立即落到最终注释区;真正写入在 close() 里的 write_annotations()。
四、关闭阶段:最终把哪些字段补全
MainActivity.close()(L882-L918)关键顺序:
- 若是恢复场景,先
replayLabelsFromSidecar()(L886-L888) - 写入
Record start/Record end两条注释(L889-L903) - 调
file_out.close() - 清理状态和 sidecar 文件(L909-L914, L963-L974)
EDFwriter.close()(L823-L844)关键二进制动作:
seek(236)回写 datarecords 到头字段write_annotations()把annotationslist编码并写入注释信号区file_out.close()
因此,真正“文件完成态”要以 close 后为准:record 数、注释区都会在这一刻最终一致。
五、断点续写(重点)
5.1 Android 原生恢复入口
repairAndResumeEdfFile(...)(MainActivity.kt L754-L787)流程:
- 文件不存在 -> 退化为新建(L756-L759)
readResumeStartDateTime(file)读旧头起始时间(L762)EDFwriter(path, filetype, signals, false)(L764)
关键是false:不清空原文件长度- 重新调用
configureEdfWriter(...)(L765)恢复同一组信号参数 - 开启 sidecar 回放模式(L766)
prepareForResume(...)返回resumedCount(L767-L775)mNum = resumedCount(L775),后续注释 onset 对齐到既有记录末尾
5.2 读取旧头起始日期时间(直接二进制读)
readResumeStartDateTime(...)(L789-L813):
seek(168)-> 读 8 字节日期- 连续读 8 字节时间(即 offset 176)
ISO_8859_1解码 +dd.mm.yy/hh.mm.ss解析- 两位年份按 EDF 常见规则还原:
>= 85->19xx- 其他 ->
20xx
这一步确保续写后头字段里的开始时刻与原文件一致(不会重置为恢复时刻)。
5.3 prepareForResume 的“修复 + 对齐”机制
EDFwriter.prepareForResume(...)(L304-L349)核心:
- 设置 start date/time(L310-L315)
- 读取
originalLength(L317) - 调
write_edf_header()(L318)按当前参数重写头 - 计算:
headerSize = (signals + annot + 1) * 256(L323)payloadSize = originalLength - headerSize(L324)completeRecords = payloadSize / recordsize(L329)repairedLength = headerSize + completeRecords * recordsize(L330)
- 若末尾有半条/坏尾巴,
setLength(repairedLength)截断(L341-L343) datarecords = completeRecords(L345)signal_write_sequence_pos = 0(L346)seek(repairedLength)(L347)把写指针放到可续写位置- 返回
completeRecords(L348)
这就是断点续写最核心价值:宁可丢弃尾部不完整数据,也保证 EDF 结构合法且可继续 append。
5.4 Dart 侧恢复协同:会话 + 补零
只靠 prepareForResume 只能续到“最后完整 record”,无法覆盖进程中断期间的时间空洞。Dart 侧补了第二层:
a) 会话文件
EdfResumeSession.start() 在录制开始时写 edf_resume_session.json,包含:
isRecordingfilePathlastWriteAtMs
写入过程中周期性 touchLastWrite() 刷新时间戳。
b) 启动后恢复
home.dart _resumeEdfAfterCrashIfNeeded()(L983-L1028):
- 读取会话
- 调
edfManager.repairAndResumeEdfFile(filePath)获取resumedCount - 计算
gapSeconds = now - lastWriteAtMs - 得到
pendingCrashResumeFillSeconds
c) 补零填洞
_fillCrashResumeZeroIfNeeded()(L1030-L1042)循环 insertDefaultData500(),按秒补默认数据,把“中断空窗”补成连续时间轴。
结论:本项目的“断点续写”是双层机制:
- 层1(原生):结构修复 + 文件指针对齐(
prepareForResume) - 层2(Dart):时间连续性补偿(按 gap 秒数补零)
六、逐动作到 EDF 改动的对照表
| 代码动作 | 位置 | EDF 改动 |
|---|---|---|
| 新建 writer + 配参数 | MainActivity.kt L711-L752 |
仅内存参数变化,尚未完整写文件体 |
| 首次写样本 | writePhysicalSamples() L1482-L1489 |
写 header(若尚未写) |
| 每次样本写入 | writePhysicalSamples() L1500-L1537 |
写当前信号原始样本 bytes 到 payload |
| 一个 data record 完成 | L1542-L1549 | 写 TAL 时间锚 + datarecords++ |
| 打标签 | MainActivity.kt L847-L853 / L868-L869 |
注释先入内存列表;sidecar 追加 JSON 行 |
| 关闭 | close() + EDFwriter.close() |
回写头的记录数、批量写注释区、关闭文件 |
| 断点恢复 | prepareForResume() |
重写头、按整记录截断坏尾、seek 到末尾继续写 |
七、几个容易误判的点
-
mNum不是 EDF 内部datarecords的唯一真相。
mNum在业务层按type==0增长;而 EDF 的datarecords只有在完整信号轮次结束后才增。 -
writeAnnotation()不是即时写盘。
真正写注释字节在close()的write_annotations()。 -
断点恢复会“主动截断文件尾”。
这是设计行为,不是数据丢失 bug;目的是把不完整尾记录修正为可解析 EDF。 -
sidecar 的意义是“防止标签丢失/重复”。
恢复场景replayLabelsFromSidecar()先去重再回放,降低崩溃期间注释遗漏风险。
八、一次典型崩溃续写时序(可用于排障)
- 录制中崩溃,EDF 可能停在半条 record 中间
- 下次启动读取会话文件,拿到
filePath + lastWriteAtMs - 调
repairAndResumeEdfFile prepareForResume计算完整 record 数并截断坏尾mNum对齐到resumedCount- 恢复采集并继续 append
- 按
gapSeconds补零写入,修复时间连续性 - 结束时
close()回写 record 数 + 注释,删除 sidecar 与会话文件
九、如果你要验证“某行是否真的改了 EDF”
建议最小验证脚本(思路):
# 1) 记录恢复前文件长度
# 2) 调用 repairAndResumeEdfFile
# 3) 再记录长度,检查是否被截断到 header + N * recordsize
# 4) 连续写几秒数据并 close
# 5) 用 reader 读取 numDataRecords,核对增长
关键核对点:
- 恢复后长度是否是
headerSize + k * recordsize readEdfFile读取的numDataRecords是否与预期一致- 关闭后注释是否存在(含
Record start/end与业务标签)
以上即当前代码下,EDF 文件从“创建 -> 持续写入 -> 注释 -> 关闭 -> 崩溃恢复 -> 续写补洞”的完整工程级解析。