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 是一个动态 Map,key 是属性名,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 的参数结构基本一致,但有个细节容易踩坑:如果你要清空某个属性,必须显式传空数组或者 null,Notion 才会把旧值覆盖掉,否则会静默保留原值。这个行为在 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 判断是否需要更新。
我在实际项目中用的是两层策略:
- 轻量拉取:定期调用 queryDatabase,pageSize 设小一些,只拿最近更新时间靠近当前时间的页面;
- 增量更新:命中变更页面后,再按 page_id 批量 retrieve 块的完整内容,做本地合并。
这种组合在鸿蒙真机上跑起来非常稳。不要每次全量拉取所有块,Notion 的块结构是递归的,一个大页面可能挂几百个子块,全量拉下来既慢又费电。
4.3 限流与幂等:加入滑动窗口限速和请求重试
Notion API 的限流策略是每个集成 3 个请求/秒左右,超了会返回 429。自动化同步一旦跑起来,很容易触发限流。我在兼容层里加了一个滑动窗口限速器,逻辑很简单:维护一个请求时间戳列表,超过窗口容量就等待。
class SlidingWindowRateLimiter { final int maxRequests; final Duration window; final Queue<DateTime> _hits = Queue(); Future<void> 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 Future<void>.delayed(Duration(milliseconds: waitMs)); } _hits.add(DateTime.now()); } }这个限速器不要放在 UI 层,要放在网络兼容层的最前面,和自定义 HttpClient 绑定。这样不管业务代码从哪里发起请求,都会先过限速器。
另外重试策略一定要针对 429 单独处理。简单的指数退避就够了:第一次失败等 1 秒,第二次 2 秒,第三次 4 秒,最多重试 5 次。不要一看到错误就立刻重试,否则会把自己封禁。
4.4 一个最小可用的同步引擎:轮询 + 增量 + 冲突兜底
把上面的增量策略整合起来,一个最小同步引擎长这样:
class SyncEngine { DateTime? _lastSyncAt; Future<void> 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 调试时可以直接指定设备 ID:
flutter run -d <ohos-device-id>我踩过的坑是:HAP 安装路径必须和签名产物路径对上,如果签名和构建类型不匹配,安装后启动会静默失败。另外,鸿蒙 Flutter 的热重载和 Android 同命令行的热重载不完全一致,改完 Dart 代码后建议先保存再触发热重载,否则偶发出现 UI 不刷新。
5.3 不要在高频网络任务里开太多 Isolate
syncing 和 CRUD 都是高频网络任务,但鸿蒙真机上资源分配比 Android 更谨慎。我在早期版本里为每个页面解析任务开一个 Isolate,结果内存暴涨,甚至触发系统级回收。后来改成单 Isolate + 消息队列的方式,性能反而更稳定。
如果确实需要在后台解析大响应体,建议只开一个常驻 worker Isolate,通过 SendPort 传递任务和结果。这样避免频繁创建和销毁 Isolate 的开销,也更容易控制并发度。实测在同一个真机上,单 worker 方案比多 Isolate 方案节省约 40% 内存峰值。
6. 实测效果与可复用的适配清单
6.1 核心功能跑通情况与耗时统计
我把标题里的核心能力在鸿蒙真机上逐项做了验证,结果如下:
| 功能模块 | 是否跑通 | 单次耗时(典型值) | 备注 |
|---|---|---|---|
| 工作区全量连接 | 是 | 0.8–1.5 秒 | 首次握手偏慢,之后复用连接 |
| 数据库项查询 | 是 | 0.4–1.2 秒 | pageSize=50 时最稳 |
| 数据库项创建/更新 | 是 | 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 并设置 connectionTimeout;
- Debug 阶段临时放开证书回调,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 相关工具,可以直接复用这套底座。个人建议是:如果你也在做类似适配,优先把传输层做实,后面所有功能都会跟着变稳。