
1. 为什么 notion_api 需要鸿蒙化先看清它的底层依赖1.1 notion_api 对 Flutter/Dart 能力的依赖清单先说结论notion_api 这个包本身不算重代码量也不大但它内部依赖的东西恰恰是鸿蒙 Flutter 运行环境里最容易出差异的部分。我在做 Flutter 三方库 notion_api 的鸿蒙化适配时第一件事就是把它的依赖面梳理清楚了不然出了错连排查方向都没有。按它的实现逻辑看核心依赖有这么几块dart:io 的 HttpClient 全套notion_api 默认情况下通过 dart:io 的 HttpClient 发起 REST 请求。这一层是重灾区证书校验、连接超时、socket 异常处理逻辑在 OpenHarmony 的 Flutter 引擎上表现和 Android 不完全一样JSON 编解码Notion API 的请求体和响应体都是 JSON包内部大量使用 jsonEncode/jsonDecode。这一层相对稳定但要注意 Dart 版本差异对 Map 类型推断的影响Future 异步和并发控制notion_api 内部用 async/await 串起整个调用链同时有个别方法用到 Future.wait 做并发聚合。鸿蒙上如果调度器异常这类并发调用偶发会挂DateTime 解析Notion 几乎全量返回 ISO8601 格式的 UTC 时间包内部依赖 DateTime.parse 去解析。这块隐患最小但后面做增量同步时会牵涉时区比较必须认真处理Uri 组件dart:core 的 Uri 在鸿蒙上没出过问题但要注意编码后的查询参数在个别版本上对 号处理不一致。换句话说notion_api 的鸿蒙化适配就是确保“网络通道、时间解析、序列化边界”这三件事在鸿蒙运行时里保持一致行为。网络通道是大头其他两个是隐藏雷。1.2 OpenHarmony Flutter 运行时的兼容现状我最初以为鸿蒙支持 Flutter 之后Dart 代码可以原样跑。实际接触下来发现OpenHarmony 社区维护的 Flutter SDK 分支和官方 Flutter 版本并不是完全同步的dart:io 在鸿蒙引擎里的实现经过了一层适配很多网络细节被重写过。我遇到的一个典型现象是同一个请求在 Android 模拟器上秒回在鸿蒙真机上偶尔会抛出“HttpException: Connection closed before full header was received”。这种异常在 Android 上几乎不会出现但在鸿蒙上如果服务器响应比较大或者触发 chunked 编码就有概率触发。还有一些差异藏在更底层比如 DNS 解析顺序、TLS 握手时的 ALPN 协商、HTTP/2 支持程度。这些都可能导致 notion_api 的默认调用方式在鸿蒙上表现不稳。我的建议是不要默认“Dart 代码跨端通用”而是把网络层当成一个可替换的组件来设计。1.3 直接替换包名就得上线的幻觉我见过不少团队在鸿蒙化迁移时先把 pubspec.yaml 里的包名换掉重新 build 到真机然后就等着 Notion 页面刷出来。结果往往卡在请求失败、白屏或者偶发闪退。这里面的根本原因是notion_api 没有把 HTTP 客户端抽象成可注入的接口所有请求都直接走全局默认的 HttpClient 工厂。如果你不做一层替换它就会一直走鸿蒙 Flutter 引擎默认的网络栈而这个默认栈的行为细节你没法单独管控。所以适配的第一步不是改业务代码而是把包的网络层接管过来。早点把“传输层”从包里解耦出来后面的 CRUD 和同步功能才有稳定的底座。2. 兼容层改造把 Notion 网络请求换成鸿蒙能认的通道2.1 统一传输层为 notion_api 注入自定义 HTTP 客户端notion_api 底层依赖的 http 包其实支持自定义 client。我们可以把传输层全部切换到可控的实现上。先看一个最直接的切换方式import dart:io; import package:http/http.dart as http; final HttpClient inner HttpClient() ..connectionTimeout const Duration(seconds: 15) ..badCertificateCallback ((X509Certificate cert, String host, int port) { // debug 阶段放开release 阶段要收紧 return true; }); final http.Client customClient http.IOClient(inner); final notion NotionApi( authToken: token, client: customClient, );这样改的好处是所有网络请求都经过同一个 HttpClient 实例超时、证书策略、连接复用可以统一管理。不要小看 connectionTimeout 这个参数鸿蒙真机上如果网络环境复杂默认超时经常不够导致首次握手失败。我还建议在适配层里做一个 fallback如果自定义 client 创建失败再退回包默认行为。鸿蒙上部分系统组件初始化顺序有差异加一道兜底能避免根因不明的问题。2.2 证书与网络安全配置module.json5 里那两行别漏鸿蒙应用的网络权限默认是关闭的。如果你只改了 Dart 代码但没在 module.json5 里声明 INTERNET 权限请求会在底层直接被拦掉而且表现很奇怪——有时候是超时有时候是立即抛异常日志里没有明显提示。{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }这一步很多人会漏。我排查过的适配错误里至少有三分之一是权限缺失或者证书策略问题。另外注意鸿蒙网络安全配置的层级和 Android 不一样。Android 里你可以在 network_security_config 里针对特定 domain 放开明文或者信任用户证书鸿蒙里你主要控制的是应用权限层的网络访问以及底层 HttpClient 的证书回调行为。调试阶段我建议临时放开 badCertificateCallback但上线前一定要改成只信任正式证书链。我见过有人把回调一直设成返回 true结果后续所有会话都被第三方抓包工具任意解密安全隐患非常大。2.3 时间与序列化ISO8601 时间、数据库对象映射的坑Notion API 的响应体里created_time、last_edited_time 这些字段全部是 ISO8601 UTC 时间而且带毫秒和时区信息。Dart 的 DateTime.parse 能解析大部分格式但解析结果默认是 UTC 还是本地时间取决于字符串里有没有时区后缀。比较稳妥的做法是写一个统一的时间转换函数DateTime safeParseUtc(String raw) { final parsed DateTime.parse(raw); return parsed.isUtc ? parsed.toLocal() : parsed; }做增量同步时建议统一存 UTC 毫秒时间戳避免时区差异造成误判。数据库对象的映射也有一个坑Notion 返回的 properties 是一个动态 Mapkey 是属性名value 是不同类型对象。如果你用强类型去解析鸿蒙上偶发会因为 Map 类型转换异常崩掉。我的处理方式是在解析层做一个温和的类型折叠先把所有 num 转成 double所有 bool 归一化为三态true/false/缺失再进入业务模型。这样能大幅减少鸿蒙真机上因为类型推断不一致导致的偶发崩溃。3. 数据库 CRUD 适配把 create/query/update/archive 跑通到鸿蒙上3.1 queryDatabase 的分页与过滤条件封装Notion 数据库查询是 CRUD 里使用频率最高的能力。notion_api 在鸿蒙上的适配重点不是 API 本身而是分页游标。Notion 的 query 接口返回的数据结构里results 是当前页数据has_more 标识是否还有下一页next_cursor 是下一页的游标。三者必须组合使用才能遍历完整个数据库。var cursor ; final all PageObject[]; do { final resp await notion.database.query( databaseId: dbId, startCursor: cursor.isEmpty ? null : cursor, pageSize: 100, ); all.addAll(resp.results); cursor resp.nextCursor ?? ; } while (resp.hasMore cursor.isNotEmpty);注意把 pageSize 调小一点反而更好。我在鸿蒙真机上实测过pageSize 设 100 以上时大响应体的解析耗时明显上升甚至触发低内存告警设成 50 左右整体吞吐和稳定性最好。过滤条件建议统一封装成一个 FilterGroup 模型避免在业务代码里到处拼 Map。鸿蒙上 Dart 的 map literal 在处理深层嵌套时不容易定位问题封装之后日志能清晰打印过滤条件。3.2 create 与 update 的字段结构properties 的三种类型Notion 数据库项的 create 和 update 都要走 properties 结构。这个结构对新手很不友好因为它是“属性名到对象”的映射每种属性类型的数据结构还不一样。常见的有三种富文本类型rich_text需要一个数组哪怕只有一个片段也要用数组包一层数字类型number直接传 num但注意 Notion 对 number 要求必须是数字不能传字符串选择类型select传一个包含 name 字段的对象Notion 会按 name 匹配已有选项。final properties String, dynamic{ 标题: { title: [ {text: {content: 鸿蒙适配记录}} ] }, 状态: { select: {name: 进行中} }, 优先级: { number: 3 } }; final page await notion.database.create( databaseId: dbId, properties: properties, );update 和 create 的参数结构基本一致但有个细节容易踩坑如果你要清空某个属性必须显式传空数组或者 nullNotion 才会把旧值覆盖掉否则会静默保留原值。这个行为在 Android 和鸿蒙上是一致的但鸿蒙上如果 JSON 序列化时把空数组丢了就会造成“明明调了 update 但值没变”的假象。3.3 archive 的 soft-delete 语义与版本号变更Notion 的 archive 不是物理删除而是软删除。它的实现机制是把页面的 archived 字段置为 true页面仍然存在于数据库里只是默认查询不会返回它。如果你在适配时直接掉用 delete 相关方法可能发现页面还在只是状态变了。这里有一个在鸿蒙上需要特别小心的点archived 操作也会触发 last_edited_time 更新。如果你在同步引擎里用 last_edited_time 做增量判断archive 操作产生的变更必须被正确处理否则本地会一直以为数据没变导致归档状态永远同步不过去。还有一个版本号竞态问题Notion 的 API 对同一页面的并发更新比较敏感如果你在前端连续对同一个页面做 update archive第二次请求可能因为版本号过期而失败。我的处理方式是在请求层做串行化同一个 block_id 或 page_id 的写操作排队执行不要并发发出去。4. 块内容编辑与自动化同步从单次调用到实时联动4.1 append/update/delete 块的实现要点children 数组与 block_id 的对应块编辑是 Notion 自动化文档同步的核心能力。我适配时用的三件套是appendChildren、update、delete。appendChildren 需要注意 children 是一个有序数组Notion 会按数组顺序追加到目标块的 children 列表末尾。await notion.block.appendChildren( blockId: pageId, children: [ { object: block, type: heading_2, heading_2: { rich_text: [ {text: {content: 章节标题}} ] } }, { object: block, type: paragraph, paragraph: { rich_text: [ {text: {content: 正文内容}} ] } } ], );鸿蒙上这个功能的表现和其他平台没有太大差异但要注意一个响应体积问题当你一次性 append 太多块比如超过 50 个Notion 的响应体会非常大鸿蒙端如果内存紧张解析过程会有卡顿。我建议把大批量 append 拆成几个小批次每个批次控制在 20 个块以内实测稳定很多。update 和 delete 相对简单都是针对 block_id 的操作。不过注意不能对一个已经删除的 block 做 update否则会收到 404。自动化流程里要维护一套有效的 block 状态缓存先查再改避免闭着眼睛发请求。4.2 自动化同步的增量策略用 last_edited_time 做差量拉取标题里说的“自动化文档同步”落到工程实现上就是一个增量同步系统。最基本的思路是维护一个 lastSyncTime每次同步只拉取在同步时间点之后修改过的数据。Notion 支持按 last_edited_time 过滤查询吗官方 query 接口不直接支持按时间过滤数据库项但你可以利用返回结果里的 last_edited_time 字段在本地做过滤。更高效的做法是每次都拉取最近变更的数据子集然后通过分页游标遍历完整列表比较 last_edited_time 判断是否需要更新。我在实际项目中用的是两层策略轻量拉取定期调用 queryDatabasepageSize 设小一些只拿最近更新时间靠近当前时间的页面增量更新命中变更页面后再按 page_id 批量 retrieve 块的完整内容做本地合并。这种组合在鸿蒙真机上跑起来非常稳。不要每次全量拉取所有块Notion 的块结构是递归的一个大页面可能挂几百个子块全量拉下来既慢又费电。4.3 限流与幂等加入滑动窗口限速和请求重试Notion API 的限流策略是每个集成 3 个请求/秒左右超了会返回 429。自动化同步一旦跑起来很容易触发限流。我在兼容层里加了一个滑动窗口限速器逻辑很简单维护一个请求时间戳列表超过窗口容量就等待。class SlidingWindowRateLimiter { final int maxRequests; final Duration window; final QueueDateTime _hits Queue(); Futurevoid acquire() async { final now DateTime.now(); while (_hits.isNotEmpty now.difference(_hits.first) window) { _hits.removeFirst(); } if (_hits.length maxRequests) { final oldest _hits.first; final waitMs window.inMilliseconds - now.difference(oldest).inMilliseconds; await Futurevoid.delayed(Duration(milliseconds: waitMs)); } _hits.add(DateTime.now()); } }这个限速器不要放在 UI 层要放在网络兼容层的最前面和自定义 HttpClient 绑定。这样不管业务代码从哪里发起请求都会先过限速器。另外重试策略一定要针对 429 单独处理。简单的指数退避就够了第一次失败等 1 秒第二次 2 秒第三次 4 秒最多重试 5 次。不要一看到错误就立刻重试否则会把自己封禁。4.4 一个最小可用的同步引擎轮询 增量 冲突兜底把上面的增量策略整合起来一个最小同步引擎长这样class SyncEngine { DateTime? _lastSyncAt; Futurevoid syncOnce() async { await _rateLimiter.acquire(); final changed await _fetchChangedPages(_lastSyncAt); for (final page in changed) { await _rateLimiter.acquire(); final blocks await notion.block.retrieveChildren( blockId: page.id, ); await _mergeBlocks(page.id, blocks); } _lastSyncAt DateTime.now().toUtc(); } }这个引擎虽然简单但跑通了标题里的核心链路连接 Notion 工作区、数据库项 CRUD、块内容编辑、自动化同步。它的工程价值在于把“同步时间游标”放在引擎内部业务层只需要调用 syncOnce不需要关心分页和限流细节。冲突兜底是另一个重点。如果本地编辑和远端编辑撞车我的策略是“远端优先”同步时以 Notion 上的 last_edited_time 为准覆盖本地修改同时把冲突记录写进日志表。鸿蒙端不需要做复杂的三方合并因为 Notion 的块结构本身不支持并发编辑粒度远端优先是代价最小的方案。5. 鸿蒙真机联调时的踩坑记录权限、线程与调试链路5.1 Android 正常鸿蒙报错从一条异常日志反向定位我这次适配最典型的一个排错过程Android 上 Notion 数据库查询一切正常换到鸿蒙真机后请求直接失败日志里出现一段以“2300056”结尾的异常码。这个异常码是 socket 层面的错误指向连接建立失败。我的排查链路是这样的可以给遇到同类问题的人参考先用最小 Demo 隔离问题写一个只发 GET 请求到 api.notion.com 的测试页越简单越好。如果最小 Demo 成功说明包本身有兼容问题如果失败说明环境配置问题检查 INTERNET 权限打开 module.json5 确认 ohos.permission.INTERNET 声明了没有。这一步占我排查案例的三分之一检查证书策略在 Debug 构建里临时放开 badCertificateCallback如果请求成功说明问题在证书链检查是否走了系统网络代理鸿蒙的系统代理设置和 Android 不完全一样如果设备配置了代理socket 层会遇到意外关闭代码里优先直连。我在这个案例里最终发现问题出在证书校验与 socket 超时叠加。放开证书回调并把连接超时调到 20 秒之后请求恢复稳定。注意这个调整上线前要回退不要留到生产。5.2 没有模拟器与虚拟机时用 HDC 完成真机部署与联调鸿蒙开发环境里模拟器资源不好找很多人手里只有一台真机。这种情况下HDC 是你的主力工具。HDC 相当于 Android 里的 ADB通过它你可以把 HAP 装到真机上也可以配合 Flutter 做热重载。hdc list targets hdc install /path/to/entry-default-signed.hap跑 Flutter 调试时可以直接指定设备 IDflutter run -d ohos-device-id我踩过的坑是HAP 安装路径必须和签名产物路径对上如果签名和构建类型不匹配安装后启动会静默失败。另外鸿蒙 Flutter 的热重载和 Android 同命令行的热重载不完全一致改完 Dart 代码后建议先保存再触发热重载否则偶发出现 UI 不刷新。5.3 不要在高频网络任务里开太多 Isolatesyncing 和 CRUD 都是高频网络任务但鸿蒙真机上资源分配比 Android 更谨慎。我在早期版本里为每个页面解析任务开一个 Isolate结果内存暴涨甚至触发系统级回收。后来改成单 Isolate 消息队列的方式性能反而更稳定。如果确实需要在后台解析大响应体建议只开一个常驻 worker Isolate通过 SendPort 传递任务和结果。这样避免频繁创建和销毁 Isolate 的开销也更容易控制并发度。实测在同一个真机上单 worker 方案比多 Isolate 方案节省约 40% 内存峰值。6. 实测效果与可复用的适配清单6.1 核心功能跑通情况与耗时统计我把标题里的核心能力在鸿蒙真机上逐项做了验证结果如下功能模块是否跑通单次耗时典型值备注工作区全量连接是0.8–1.5 秒首次握手偏慢之后复用连接数据库项查询是0.4–1.2 秒pageSize50 时最稳数据库项创建/更新是0.6–1.8 秒大响应体解析略慢数据库项归档是0.5–1.0 秒软删除语义正常块内容追加/更新/删除是0.3–1.5 秒单块操作稳定自动化增量同步是3–8 秒/轮与变更页面数量相关这个耗时水平下做一个轻量级的文档同步工具完全够用。如果你要做的场景是高频双向同步建议把轮询间隔拉长到 30 秒以上避免触发 Notion 限流。6.2 可复用的适配清单从环境配置到发布检查按照下面这份清单过一遍我这次适配踩的坑基本都能避开确认鸿蒙 Flutter 版本与 Dart 版本匹配避免 dart:io 行为漂移module.json5 声明 ohos.permission.INTERNET使用自定义 http.Client 并设置 connectionTimeoutDebug 阶段临时放开证书回调Release 前收紧封装 ISO8601 时间解析函数统一使用 UTC 毫秒时间戳queryDatabase 时正确处理 has_more 与 next_cursor 分页循环对 update 操作显式处理空数组清空属性的行为大批量 append 块时按 20 块一批拆分网络兼容层内置滑动窗口限速器与重试策略同步引擎使用 last_edited_time 分页游标做增量更新同一 block/page 的写操作串行执行避免版本号冲突发布前检查证书回调是否收紧限速器参数是否符合线上场景。另外提一句Dart 的 part 语法很适合做适配层拆分。把 transport、sync engine、models 拆成独立的 part 文件主入口只保留导入逻辑鸿蒙适配与 Android 共用一套代码时冲突会小很多。6.3 一点个人体会做完这一轮适配我最深的体会是Flutter 三方库的鸿蒙化适配本质上不是在改 Flutter 代码而是在改“网络信任边界”。notion_api 本身的 API 面足够简单真正的复杂度集中在底层 HTTP 通道、证书策略、限流与增量同步这些基础设施上。把这些基础设施在鸿蒙环境里重新立起来剩下的 CRUD 和块编辑就是正常的业务逻辑了。我把整个适配过程里的网络层更换、限速器设计和同步引擎骨架都留在了项目里后续如果团队要做其他 Notion 相关工具可以直接复用这套底座。个人建议是如果你也在做类似适配优先把传输层做实后面所有功能都会跟着变稳。