
上个月接了个鸿蒙化的适配需求把项目里基于 Flutter 的通讯录功能整体迁到鸿蒙设备上。项目里的联系人模块一直用的都是 contacts 这个三方库本身在 Android 和 iOS 上跑得挺稳结果一到鸿蒙这边编译期就先卡住了——pubspec 里根本没有 ohos 平台的实现装依赖的时候直接提示找不到对应平台的原生模块。我翻了半天 Flutter 官方插件仓库contacts 相关的鸿蒙适配基本还处于空白状态。网上能搜到的资料大多是把项目跑起来级别的 Demo真正深入到通讯录数据读写、权限动态申请、联系人变更订阅这一层的内容非常少。没办法只能自己动手给 contacts 做鸿蒙化适配。这篇文章就是把整个适配过程、踩过的坑、以及最终沉淀下来的代码结构完整梳理一遍希望能给后面接手类似工作的朋友省点时间。1. contacts 插件在鸿蒙生态上失灵的原因适配前必须搞清的三个问题1.1 三端插件架构下缺了哪一环先理解一下 Flutter 插件跨平台的底层结构。一个标准插件在 pubspec.yaml 里有这样一段声明flutter: plugin: platforms: android: package: com.flutter.dev.contacts pluginClass: ContactsPlugin ios: pluginClass: ContactsPlugin这段声明决定了 Flutter 引擎在对应平台上会去找哪个原生类来注册 MethodChannel。Android 平台对应的是 Java/Kotlin 代码iOS 对应的是 Objective-C/Swift 代码。如果某个平台没有在 platforms 下声明Flutter 仍然能编译但一旦你的 Dart 代码调用MethodChannel.invokeMethod原生侧根本不会有任何对象去响应调用结果就是MissingPluginException。contacts 这个库目前官方只适配了 Android、iOS、Web 三个平台。鸿蒙OpenHarmony需要的是在 platforms 下新增一个类似这样的一段ohos: pluginClass: ContactsPlugin dartPluginClass:这里的 pluginClass 指向鸿蒙侧用 ArkTS 写的原生类它负责从 Flutter 引擎接收方法调用再操作系统通讯录 API。缺的就是这一整条链路。1.2 鸿蒙侧通讯录能力与 Flutter 的会话通路鸿蒙系统本身提供了完整的通讯录能力分布在ohos.contactAPI 9 到 API 11 的模块名和kit.ContactKitAPI 12 开始的新模块名这两个导入路径下面。它支持读取联系人列表、按条件查询、新增、删除、修改联系人还能订阅联系人数据变化的回调。但问题是鸿蒙的这些能力只暴露在原生层。Flutter 的 Dart 代码和鸿蒙原生代码之间隔着一层消息通道官方推荐的做法是MethodChannel双向方法调用适合一次请求一次响应的场景比如读取所有联系人新增一个联系人。EventChannel单向事件推送适合联系人发生变化了主动通知 Dart 层这种场景。contacts 这个库在 Android/iOS 上主要是走 MethodChannel鸿蒙化适配也是同样的套路。搞清楚通道类型后面写代码的大方向就定下来了。1.3 适配方案选型补原生实现还是另起炉灶拿到这个需求时我大概有两个方向可以选方向一fork 一份 contacts 源码在插件工程里新增 ohos 目录实现官方插件的原生侧接口。这样做的好处是业务层调用的 Dart API 完全不用改原来怎么用ContactsService.getContacts()现在就怎么用迁移成本低。坏处是 forks 的代码需要自己维护后续 contacts 上游更新要手动合并。方向二在 Dart 层写一个条件编译的封装检测到当前是鸿蒙平台时走自己新写的一套原生通道和 API。好处是完全不依赖上游插件的内部结构但坏处是业务侧要飘鸿蒙专属的逻辑侵入性大。我最后选了方向一。原因是项目里联系人相关的调用点非常多分散在好几个页面和 service 层方向一能做到业务侧零感知。如果你只是在一个页面里用一下联系人方向二反而更快。两者没有绝对的对错按项目现状取舍。2. 环境准备与工程改造把 contacts 接进 Flutter 鸿蒙工程的第一步2.1 鸿蒙侧工程结构ohos 目录与 Flutter 模块的嵌套关系鸿蒙适配 Flutter 插件的工程结构和 Android 类似。以当前 OpenHarmony 官方 Flutter 分支的做法为例一个支持鸿蒙的 Flutter 项目通常长这样your_flutter_project/ ├── pubspec.yaml ├── android/ ├── ios/ └── ohos/ ├── entry/ │ └── src/main/ │ ├── ets/ │ │ ├── entryability/ │ │ └── pages/ │ └── module.json5 ├── ContactsPlugin/ │ └── src/main/ │ ├── ets/ │ │ └── ContactsPlugin.ets │ └── module.json5 └── build-profile.json5如果你是要给 contacts 这个第三方库加 ohos 支持实际操作是在插件仓库根目录下新建一个ohos/目录里面再建一个类似ContactsPlugin的原生模块。这个模块最终会以 HARHarmony Archive的形式被宿主工程依赖。需要注意的是DevEco Studio 里模块命名的规范和 Flutter 侧的 pluginClass 字符串必须一致否则注册时会找不到类。2.2 module.json5 静态权限声明这只是开始通讯录属于用户隐私数据鸿蒙系统对这类权限的管理非常严格。首先要在接入方的module.json5里声明权限{ module: { requestPermissions: [ { name: ohos.permission.READ_CONTACTS, reason: 读取联系人用于展示和搜索, usedScene: { abilities: [ EntryAbility ], when: inuse } }, { name: ohos.permission.WRITE_CONTACTS, reason: 写入联系人用于新增和编辑, usedScene: { abilities: [ EntryAbility ], when: inuse } } ] } }reason 字段建议写得具体一点因为鸿蒙的应用市场审核会对照这个原因来判断你的权限使用是否合理。usedScene 里的 when 字段如果写always审核会更严格。大部分联系人操作都是用户在前台发起所以填inuse就够了。静态声明只是第一步这两个权限在鸿蒙的权限模型里属于 user_grant 类型必须在运行时通过abilityAccessCtrl.requestPermissionsFromUser动态申请用户授权后才能真正拿到访问能力。这个后面第 4 节专门讲。2.3 从 MethodChannel 注册到原生模块导出的最小骨架鸿蒙侧插件的入口类需要继承FlutterPlugin或实现类似能力的接口。在 OpenHarmony Flutter 分支的约束下一个最小可用的插件骨架是这样import { FlutterPlugin, MethodCall, MethodResult } from ohos/flutter_ohos/flutter_plugin; import { MethodChannel } from ohos/flutter_ohos/channel; export default class ContactsPlugin implements FlutterPlugin { private methodChannel: MethodChannel | null null; onAttachedToEngine(binding): void { this.methodChannel new MethodChannel(binding, contact_plugin); this.methodChannel.setMethodCallHandler(this.onMethodCall.bind(this)); } private onMethodCall(call: MethodCall, result: MethodResult): void { switch (call.method) { case getContacts: this.getContacts(result); break; case addContact: this.addContact(call.arguments, result); break; default: result.notImplemented(); } } }这里我踩过一个坑通道名contact_plugin必须和 Dart 侧MethodChannel(contact_plugin)完全一致大小写都不能错。而且鸿蒙的 Flutter 分支对插件生命周期管理更严格onAttachedToEngine里注册的 handler 要在onDetachedFromEngine里及时解绑否则在 DevEco 的热重启场景下容易重复注册导致同一笔请求被处理两遍。3. 原生通讯录深度集成读取、查询与数据模型映射的实现细节3.1 鸿蒙 contact API 的三种获取方式与选择鸿蒙的ohos.contact模块提供了多套获取联系人的接口我整理了一张对比表方便你直接做选型接口适用场景返回数据量备注getContacts(wantContacts)一次性拉取全部联系人全量数量大时耗时长getContact(contactId)精确获取某个联系人的详情单条配合 ID 使用queryContacts(query)按条件分页查询可控支持模糊匹配、分页我在项目里最终选的是 queryContacts 作为主路径。它不是一上来就把整本通讯录全量倒给你而是可以按pageNumber和pageSize分批取比如一次取 100 条。这样在联系人超过 500 人的场景下列表页能快速展示前几页数据用户滚动到后面再触发下一页加载体验上要比一次性全量拉取顺畅很多。全量获取 getContacts 也不是没用而是更适合做本地缓存的全量刷新比如应用启动后静默同步一版完整数据到本地数据库。两种接口配合使用一个是给界面用的一个是给缓存用的。3.2 数据模型转换Name/Phone/Email 的多值结构在 Flutter 端的还原鸿蒙的 Contact 数据模型是嵌套结构。一个联系人对象大致长这样{ contactId: 1, displayName: 张三, names: [{ fullName: 张三, givenName: 张, familyName: 三 }], phoneNumbers: [{ phoneNumber: 13800000000, label: CELL }], emails: [{ email: zhangsanexample.com }], organizations: [{ name: 某公司, title: 工程师 }] }而 Flutter 侧 contacts 库的数据模型也有自己的一套结构。它把联系人抽象成 Contact 对象里面有 name、phoneNumbers、emails、organizations 这些字段但字段的命名和值的类型都不一样。比如 Flutter 侧 phoneNumbers 是一个ListItem每个 Item 里有value和label两个字段。所以原生侧拿到鸿蒙 Contact 之后必须先做一层序列化转换把这些嵌套结构拍平成 Flutter 侧期望的 JSON 格式。我在这里给出的转换规则是// Dart 侧定义模型的简化版 class Contact { String identifier; String displayName; ListItem phoneNumbers; // Item 的 value 是号码, label 是类型标签 ListItem emails; ListPostalAddress postalAddresses; Name name; }原生侧则是在拿到 Contact 后用 JSON.stringify 把鸿蒙对象转成 map再按 Dart 侧约定的 key 名重新组织。这个 key 名的约定非常关键我建议全部用 snake_case比如phone_numbers、postal_addresses避免 Dart 侧解析时跟系统自带 JSON 解码器对不上。3.3 联系人的增删改直接操作原生数据时最容易出错的环节新增联系人走的是contact.insertContact(context, contactObject)这个相对简单。坑主要在删除和修改上。鸿蒙的联系人数据库本身有聚合逻辑它区分 RawContact原始联系人和 Unified Contact聚合后的联系人。同一个联系人的多个原始记录比如手机存的、SIM 卡存的会被系统聚合展示成一条。这意味着你通过deleteContact传入的 contactId 如果是聚合后的 ID系统会一次删掉底下的多条原始记录如果你传的是某个 RawContact 的 ID则可能只删其中一条记录界面上那条联系人还在。开发阶段最容易出问题的就是你通过 getContacts 拿到的是聚合 ID然后又用 query 接口去查原始 ID最后在 delete 的时候混用了。结果就是删了但好像又没删干净。我的建议是在插件层统一透出一个参数让业务侧明确指定删除语义。默认用聚合 ID按用户预期整条联系人删除。修改联系人的逻辑也类似。鸿蒙提供了mutateContact和updateContactById两种入口前者的参数是完整的 Contact 对象后者的参数是 ID 加待更新的属性。实测下来如果你只是改一个手机号用 updateContactById 传部分属性更省事还避免并发冲突时整条覆盖带来的脏数据问题。4. 权限动态管理从首次弹窗到拒绝后的优雅兜底4.1 鸿蒙运行时权限申请的正确姿势鸿蒙实现动态权限申请的标准做法是先拿到 UIAbility 的 context然后调用abilityAccessCtrl.createAtManager().requestPermissionsFromUser()。这里有几个细节申请权限时的 context 一定要是 UI 上下文不能随便拿一个全局 context 去调。用的不对弹窗根本弹不出来。可以一次性传多个权限组但建议控制在两个以内。一次性申请 READ_CONTACTS 和 WRITE_CONTACTS 是可以的鸿蒙会并排展示两个弹窗。如果申请太多权限用户的警惕心和拒绝概率都会上升。权限申请结果是一个PermissionRequestResult对象里面有一个authResults数组数组的顺序跟你传入的权限数组一一对应。这个对应关系一定要处理好不能只看有没有授权要逐项核对。let atManager abilityAccessCtrl.createAtManager(); let permissions: ArrayPermissions [ ohos.permission.READ_CONTACTS, ohos.permission.WRITE_CONTACTS ]; let res await atManager.requestPermissionsFromUser(context, permissions); if (res.authResults[0] 0 res.authResults[1] 0) { // 两个权限都拿到了,继续往下走 } else { // 至少有一个被拒绝,这里要处理拒绝逻辑 }4.2 授权结果回调与 Flutter 端的 Promise 状态同步Flutter 侧调用联系人接口时用户会经历完整的授权流程。这里有个体验问题如果 Dart 侧只是简单调用await ContactsService.getContacts()用户第一次点击时会先弹系统授权框等授权完才拿到联系人这个等待过程可能好几秒而且弹窗的时机往往不在你的控制里。我的建议是业务进入联系人页面之前先从 Dart 侧发起一次确保权限的前置调用。也就是说联系人插件的 Dart API 上加一个方法Futurebool ensurePermission() async { final result await _channel.invokeMethod(ensurePermission); return result true; }原生侧在收到 ensurePermission 调用时先检查当前的权限状态如果没授权就弹申请框等用户操作完再把最终状态返回给 Dart。这样页面层可以配合 loading 状态去管理授权流程而不是在用户滚动列表到一半时突然弹出系统授权框。4.3 用户拒绝、永久拒绝与权限变更监听的处理策略鸿蒙的权限模型里用户拒绝一次和用户永久拒绝在应用层能感知到的差别主要是看拒绝后再次申请时是否还会弹窗。如果系统不再弹窗意味着用户勾选了不再询问直接走永久拒绝逻辑。针对这个场景我的处理是授权被拒且系统还会弹窗不打断当前流程只给用户一个轻提示说明联系人功能需要权限。授权被拒系统不再弹窗引导用户跳转到系统设置页打开应用的权限管理界面。跳转用的能力是openAppSettings这个需要在原生侧封装成通道方法。另外鸿蒙还提供了权限变更监听接口。可以注册on(permissionsChange)这样即使用户在设置页里手动改了权限应用在回到前台时也能拿到最新状态从而实时刷新联系人页面的 UI。这个监听的注册和注销要放进插件的生命周期管理里避免页面重建时重复监听。5. 联系人数据处理实战批量操作、去重与本地缓存的选型5.1 大联系人库下的批量插入与事务控制如果业务场景涉及把一批联系人导入到系统通讯录最忌讳的做法是一个个循环调用 insertContact。鸿蒙的联系人写入虽然底层有数据库事务但每调一次接口都会走一遍完整的数据校验和索引更新500 条联系人一条条插慢的话能跑几十秒。实测下来比较稳妥的方法是分批并行插入。把待插入数据按 20 个一组每组用Promise.all并发执行组与组之间串行等待这样既不会一下子把系统负载拉满又能显著缩短总耗时。另外插入时要主动清理入参里的空字符串字段尤其是 emails 里的空对象鸿蒙侧校验比较严格空字段会导致整条记录插入失败而不是跳过。5.2 去重策略RawContact 与 Unified Contact 的取舍前面提到鸿蒙联系人系统有聚合机制前端界面显示的是聚合后的结果。但如果你拿到了原始联系人数据特别是从 SIM 卡或旧系统迁移过来的数据里面会充满大量重复项同一个人的两个号码、两个邮件甚至是名字不同但号码相同的记录。我的去重策略分为两层展示层去重列表页以聚合联系人为主体同一个聚合 ID 的数据不做拆分展示。写入层去重新增联系人之前先按手机号的前 7 位做一次预查询如果系统里已经存在同号联系人就跳过或交由用户确认。后者能有效避免重复导入。但要注意queryContacts 的模糊匹配在号码上的表现跟精确匹配不一样别用contains去匹配短号码否则很容易命中大量无关数据。遇到这种需求最稳的方式是拉出候选集合在内存里用号码去尾位做精确比对。5.3 数据变更订阅用 EventChannel 把原生变化推给 Dart联系人数据不是只在应用内变化的。用户在系统通讯录 App 里改了一条记录你的应用可能正开着这时候如果界面不做刷新用户会以为数据没同步。鸿蒙侧提供了订阅联系人变化的入口。实现方式是在原生侧订阅变更事件收到回调后通过 EventChannel 向 Dart 侧推送一个数据变更了的事件Dart 侧收到后再触发全量或增量刷新。这一步的工程实现有一个容易疏忽的细节EventChannel 必须在原生层维护好 stream 的生命周期。Flutter 页面在执行listen时原生侧才创建对应的事件流页面销毁时要主动调用cancel取消订阅否则事件会堆积。我在第一次实现时没有处理取消结果页面切了几次之后EventChannel 的事件回调被触发了十几次。6. 一路踩过的坑API 版本差异、真机模拟器差异与调试技巧6.1 从 API 9 到 API 12接口签名变化带来的兼容成本接手这个项目时开发机上的 DevEco Studio 已经更新到支持 API 12 的版本而设备上的系统还是 API 10 的。这意味着代码里调用的联系人接口在两套 API 上的签名不同。最典型的变化是模块导入路径。API 9 到 API 11 用的是import contact from ohos.contact;API 12 开始官方推荐用 kit 化的新路径import { contact } from kit.ContactKit;这两个路径在 API 12 上都兼容但 IDE 会提示旧路径 deprecated。更麻烦的是某些老接口在新版本里改了参数顺序比如getContacts(wantContacts)在 API 12 里多了一个回调参数的重载。做兼容的方案是在原生侧写一个工具函数层统一封装新旧接口的差异。对外只暴露一套方法签名内部根据canIUse或者 API 版本号判断走哪个实现。这样 Flutter 侧完全不用关心底层是哪个 API 版本。6.2 模拟器上联系人不可用怎么办真机调试的准备清单我在模拟器上跑第一个联系人 Demo 时发现读出来的联系人一直是空的。查了半天不是代码问题而是模拟器根本预置不了多少联系人系统通讯录里只有空壳数据。后来准备了一份测试数据清单在模拟器上提前通过contact.insertContact造了 20 条测试联系人覆盖不同号码格式、中英文姓名、带邮箱不带邮箱等场景。调试用例的覆盖面一下子充实了。但模拟器上能做的测试还是有限的。权限弹窗的视觉表现、跨应用跳转设置页、联系人聚合刷新这些体验模拟器的表现和真机还有差距。建议在提测之前在真机上完整走一遍以下清单首次启动拒绝授权再触发联系人页面的引导跳转。授权后在系统通讯录里新增、删除、修改一个联系人回应用观察事件订阅是否触发。导入 300 联系人验证分页查询和列表滚动性能。6.3 排查链路实录一次联系人变更回调不触发的定位过程这里记一个印象很深的排查过程。当时用 EventChannel 订阅联系人变更在模拟器上测试时无论我怎么在系统通讯录里删改联系人Flutter 侧就是收不到事件。第一反应是 EventChannel 的注册时机不对。我检查了插件的 onAttachedToEngine 和页面 listen 的时序确认原生侧是在收到 Dart 侧的订阅指令后才创建的 stream。没有发现问题。接着怀疑是鸿蒙的订阅 API 用错了。我查了ohos.contact.subscribeContact的文档确认回调是往联系人数据库写变化的才会触发。于是我在原生侧加了个日志直接在订阅回调里打点结果发现原生侧根本连回调都没收到。最后定位到原因模拟器的联系人存储服务ContactsProvider没有被正常拉起。系统通讯录 App 打开过一次之后服务才注册好后续订阅才能生效。换句话说如果你是冷启动后立刻订阅会错过服务注册窗口。解决方法是订阅前先模拟一次打开联系人应用或者延迟到应用完全启动后再发订阅指令。这个坑在真机上几乎不会遇到因为真机的联系人服务常驻但模拟器上非常容易复现。7. 集成验证与后续工作单元测试、集成测试与 Release 构建守则7.1 测试用例设计与断言适配做完之后测试不能糊弄。我的建议是把测试分成两层Dart 侧用 mockito 把 MethodChannel 的调用 mock 掉验证业务逻辑对返回数据的解析是否正确。重点断言这几个场景原生返回空列表时contacts 库是否能走完流程不抛异常。原生返回缺少 names 字段的数据时默认 displayName 的回退逻辑是否生效。权限被拒绝时异常信息是否能被业务层捕获并匹配上预设的错误码。原生侧这边用 DevEco 的单元测试框架写一个针对联系人转换函数的测试直接构造 Contact 对象验证序列化后的 JSON key 是否和 Dart 侧一致。这层测试不用连真机跑得快能抓一大半字段映射问题。7.2 Release 构建时的混淆与压缩配置鸿蒙 Release 构建默认会开启代码混淆。我在一次发版后遇到过联系人的 displayName 偶发为空查了很久才发现是混淆把 contact 对象里某个字段名给重写了导致 JSON 反序列化时字段对不上。后来在混淆规则里把 contact 相关的数据类统一加了 keep 配置。另外Release 包的权限声明和 Debug 包是有区别的。如果应用市场审核时发现权限使用场景不明确会被打回。提交审核前的构建产物建议自己用系统设置里的权限管理看一遍每个权限的用途说明确认和代码实际行为一致。7.3 后续可扩展方向contacts 的鸿蒙化适配做完之后联系人模块的基础能力已经通了。我接下来打算做的扩展是来电识别场景就是在来电时查到来电号码对应的联系人头像和备注直接显示在来电卡片上。这块涉及来电权限和系统回调比常规的联系人读写更麻烦。还有一块是可以把联系人变化事件和本地业务数据打通。比如用户在系统通讯录里更新了某个联系人的头像应用内的聊天列表如果引用了头像可以同步更新缓存避免出现头像长期不刷新的旧数据问题。这两块都有不少细节要先探路后面有成果了再单独写一篇分享。