MainActivity EDF 写入与断点续写全链路解析

目标与范围

本文把 MainActivity.kt 与 app 内 EDF 工具链放在一起看,给出:

  • 原生入口到 EDF 文件字节改动的完整路径
  • 哪一段代码负责哪一类 EDF 改动(头、样本区、注释区)
  • 断点续写时“修复 + 继续写 + 回放标签 + 补洞”的精确过程
  • 与 Dart 侧恢复逻辑(会话文件、补零)的联动

为避免歧义,文中“行号”均指当前仓库对应文件中的真实行号。

EDF 文件底层(本项目依赖实现)

EDFwriter.java 顶部注释明确了标准头关键偏移(ASCII 定长字段):

  • offset 168, len 8: 开始日期 dd.mm.yy
  • offset 176, len 8: 开始时间 hh.mm.ss
  • offset 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.ktconfigureFlutterEngine 里 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):

  1. 记录开始时间与标签 sidecar 初始化(L713-L714)
  2. file_out = EDFwriter(path, filetype, signals)(L717)
  3. 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):

  1. mNum++:逻辑上将“秒计数/记录计数”向前推进 1
  2. List<Double> 拷贝到 DoubleArray
  3. outputFile.writePhysicalSamples(buf):交给 EDFwriter 执行物理值->数字值转换并写字节

writePhysicalSamples()EDFwriter.java)内部行为:

  1. 根据当前 signal_write_sequence_pos 判断这是哪个通道的数据
  2. 按该通道 sampleFrequency 读取前 sf 个样本
  3. param_bitvalue/param_offset 转成整数 ADC 值
  4. 按 little-endian 写入 2 字节/样本
  5. signal_write_sequence_pos++
  6. 如果一个 data record 的所有信号都写完:
    • 写 TAL 时间锚(write_tal()
    • datarecords++

这意味着单次 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)关键顺序:

  1. 若是恢复场景,先 replayLabelsFromSidecar()(L886-L888)
  2. 写入 Record start / Record end 两条注释(L889-L903)
  3. file_out.close()
  4. 清理状态和 sidecar 文件(L909-L914, L963-L974)

EDFwriter.close()(L823-L844)关键二进制动作:

  1. seek(236) 回写 datarecords 到头字段
  2. write_annotations()annotationslist 编码并写入注释信号区
  3. file_out.close()

因此,真正“文件完成态”要以 close 后为准:record 数、注释区都会在这一刻最终一致。

五、断点续写(重点)

5.1 Android 原生恢复入口

repairAndResumeEdfFile(...)MainActivity.kt L754-L787)流程:

  1. 文件不存在 -> 退化为新建(L756-L759)
  2. readResumeStartDateTime(file) 读旧头起始时间(L762)
  3. EDFwriter(path, filetype, signals, false)(L764)
    关键是 false:不清空原文件长度
  4. 重新调用 configureEdfWriter(...)(L765)恢复同一组信号参数
  5. 开启 sidecar 回放模式(L766)
  6. prepareForResume(...) 返回 resumedCount(L767-L775)
  7. 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)核心:

  1. 设置 start date/time(L310-L315)
  2. 读取 originalLength(L317)
  3. write_edf_header()(L318)按当前参数重写头
  4. 计算:
    • headerSize = (signals + annot + 1) * 256(L323)
    • payloadSize = originalLength - headerSize(L324)
    • completeRecords = payloadSize / recordsize(L329)
    • repairedLength = headerSize + completeRecords * recordsize(L330)
  5. 若末尾有半条/坏尾巴,setLength(repairedLength) 截断(L341-L343)
  6. datarecords = completeRecords(L345)
  7. signal_write_sequence_pos = 0(L346)
  8. seek(repairedLength)(L347)把写指针放到可续写位置
  9. 返回 completeRecords(L348)

这就是断点续写最核心价值:宁可丢弃尾部不完整数据,也保证 EDF 结构合法且可继续 append

5.4 Dart 侧恢复协同:会话 + 补零

只靠 prepareForResume 只能续到“最后完整 record”,无法覆盖进程中断期间的时间空洞。Dart 侧补了第二层:

a) 会话文件

EdfResumeSession.start() 在录制开始时写 edf_resume_session.json,包含:

  • isRecording
  • filePath
  • lastWriteAtMs

写入过程中周期性 touchLastWrite() 刷新时间戳。

b) 启动后恢复

home.dart _resumeEdfAfterCrashIfNeeded()(L983-L1028):

  1. 读取会话
  2. edfManager.repairAndResumeEdfFile(filePath) 获取 resumedCount
  3. 计算 gapSeconds = now - lastWriteAtMs
  4. 得到 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 到末尾继续写

七、几个容易误判的点

  1. mNum 不是 EDF 内部 datarecords 的唯一真相。
    mNum 在业务层按 type==0 增长;而 EDF 的 datarecords 只有在完整信号轮次结束后才增。

  2. writeAnnotation() 不是即时写盘。
    真正写注释字节在 close()write_annotations()

  3. 断点恢复会“主动截断文件尾”。
    这是设计行为,不是数据丢失 bug;目的是把不完整尾记录修正为可解析 EDF。

  4. sidecar 的意义是“防止标签丢失/重复”。
    恢复场景 replayLabelsFromSidecar() 先去重再回放,降低崩溃期间注释遗漏风险。

八、一次典型崩溃续写时序(可用于排障)

  1. 录制中崩溃,EDF 可能停在半条 record 中间
  2. 下次启动读取会话文件,拿到 filePath + lastWriteAtMs
  3. repairAndResumeEdfFile
  4. prepareForResume 计算完整 record 数并截断坏尾
  5. mNum 对齐到 resumedCount
  6. 恢复采集并继续 append
  7. gapSeconds 补零写入,修复时间连续性
  8. 结束时 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 文件从“创建 -> 持续写入 -> 注释 -> 关闭 -> 崩溃恢复 -> 续写补洞”的完整工程级解析。