前阵子接了一个内部阅读类应用的鸿蒙适配需求,业务方要求把“全球百科词条”功能搬到鸿蒙设备上运行。移动端页面本来用 Flutter 写的,数据链路依赖一个叫 wikipedia_api 的三方库——说白了,这个库负责帮我们向公共百科数据源发起搜索、返回词条列表,并且支持按语言拉取对应语言的词条摘要。刚开始我以为是纯 Dart 包,配起来应该不费劲,结果从工程创建到真机运行,前后折腾了几天才跑通。
这篇文章就把这次的适配过程完整复盘一下。内容围绕 wikipedia_api 的鸿蒙化适配展开,会聊到纯 Dart 三方库在鸿蒙平台上的真实兼容情况、工程接入的完整操作、多语言检索与摘要提取的实现要点,以及真机联调阶段最容易踩的几个坑。适合正在做 Flutter 应用鸿蒙化的移动端开发者,也适合刚接手“把现有 Flutter 模块塞进鸿蒙工程”这种需求的人参考。
1. 一次 Flutter 三方库鸿蒙化的需求复盘
1.1 先说清楚这个适配需求到底要解决什么
我们遇到的需求不算复杂:公司内部一个资讯类 App,正在做鸿蒙设备适配,其中有一个“百科词条”功能模块,原来是 Flutter 页面,数据全部来自 wikipedia_api 这个三方包。业务方的要求非常朴素——在鸿蒙设备上,这个页面要能用,搜索要能搜出结果,点进词条要能显示摘要。
听起来很简单,但实际拆开之后问题就多了。
第一,鸿蒙平台和 Android/iOS 的 Flutter 运行环境不一样,很多在 Android 上跑得好好的 Flutter 三方库,到了鸿蒙上可能因为平台通道、底层 Io 能力、网络栈实现差异而报错。wikipedia_api 看起来是纯 Dart 包,内部用的也是 Dart 自带的 http 客户端,但“纯 Dart”不等于“零适配”,它仍然依赖 Flutter 引擎在网络层提供可用的通道。
第二,这个库要访问的是公共百科数据接口,返回的数据结构和不同语言的内容编码都有差异,多语言检索不是简单传一个语言参数就能解决的,至少要处理返回内容的字符编码、摘要字段的富文本标签清理、超时重试策略等。
第三,鸿蒙工程的结构和标准 Flutter 工程不一样,依赖声明、网络权限、编译配置都需要单独处理。如果不把工程层面的地基打对,后面写多少业务代码都是白搭。
所以这篇文章虽然叫“wikipedia_api 的鸿蒙化适配”,实际上是在讲一条完整的适配路径:工程怎么搭、依赖怎么引、网络怎么通、数据怎么解析、异常怎么兜底,整个过程走通了,这个库才算真正在鸿蒙平台上“能用”。
1.2 wikipedia_api 这个包在我们的项目里扮演什么角色
从功能上看,wikipedia_api 并不复杂。它把公共百科数据接口包装成 Dart 可以调用的方法,主要做了三件事:
- 按关键词搜索词条,返回候选标题列表
- 按指定语言和标题获取词条摘要
- 支持多语言切换,不同语言的搜索结果和摘要内容都会带对应语言版本
在我们的实际页面里,用户输入关键词,点击搜索,列表展示若干词条;用户点击某个词条,页面底部或者详情区域展示该词条的摘要文本。这个交互看起来基础,但背后涉及的细节不少。
比如多语言支持。我们要求的是用户切换语言后,整个词条检索和摘要都跟着变。这意味着每次切换语言,都要重新发起搜索请求,同时摘要内容也要按对应语言拉取,不能出现“搜索列表是中文、点进去摘要变成英文”这种割裂体验。
再看摘要提取。公共百科接口返回的摘要字段通常不是纯文本,会有 HTML 标签,比如加粗标签、段落标签,甚至有一些内嵌的标记结构。如果直接把原始字段展示到页面上,轻则格式混乱,重则出现标签文本裸露。所以适配过程中,必须写一层解析逻辑,把摘要字段里的 HTML 标签剥掉,再对超长内容做截断处理。
wikipedia_api 本身只负责“请求数据”这一层,摘要清洗、缓存、异常重试这些属于业务层能力,需要我们在鸿蒙适配过程中一并补上。
1.3 纯 Dart 包在鸿蒙平台的适配难度,比想象中大
很多人有一个思维定势:纯 Dart 写的东西应该哪里都能跑,鸿蒙适配无非就是重新打包。这个想法只对了一半。
纯 Dart 包确实不涉及具体的原生 UI 控件,也不存在 JNI 调用、反射、系统 API 依赖这些“硬伤”。但问题是,Flutter 引擎在鸿蒙平台上并不是 100% 等价的。举两个实际例子。
第一个是网络层。wikipedia_api 默认走 Dart 的 HttpClient,这个 Client 底层要使用 Flutter 引擎提供的网络能力。在鸿蒙平台上,如果引擎的网络栈没有对接到鸿蒙网络框架,那么即使包本身是纯 Dart,发起 HTTPS 请求时依然可能失败。我们当时在真机上遇到过一次“请求一直挂起、最后超时”的现象,排查到最后,发现不是包的问题,是应用没在鸿蒙工程里声明网络权限,请求被系统直接拦掉了。
第二个是平台通道。有些纯 Dart 包会依赖一些加密、哈希、系统时间的平台通道插件,比如通过dart:io读取系统信息、通过 Platform.isAndroid 判断平台。在鸿蒙环境里,Platform.isAndroid返回的可能是 false,因为鸿蒙设备的系统标识和 Android 并非完全一致。如果包里有针对平台的逻辑判断,很可能走错分支。
所以,最理性的判断方式是:不预判,先看源码,再在真机上验证。拿到 wiki 包后,第一时间做一次依赖分析,把所有可能触发平台差异的代码路径找出来,而不是盲目相信“纯 Dart 没问题”。
2. 鸿蒙工程接入的四个关键点,一个都不能错
2.1 先想清楚:是改造现有 Flutter 模块,还是新建鸿蒙工程
这里有一个工程形态的选择问题。
如果公司的新项目本身就是“鸿蒙应用 + Flutter 模块”的混合结构,那通常是在鸿蒙 IDE 里创建一个鸿蒙应用工程,然后把 Flutter 模块作为一个独立工程模块引入,由鸿蒙侧的页面承载 Flutter 页面。
如果是把一套已经存在的 Flutter 代码移植到鸿蒙设备上,有两种做法:一是保留现有多端 Flutter 工程,在工程里增加鸿蒙平台的构建目标;二是单独维护一个鸿蒙壳工程,把 Flutter 代码作为鸿蒙工程的模块依赖。
我们这次采取的是第二种,原因很实际:现有的 Flutter 工程包含了大量 Android 和 iOS 相关的依赖和配置,强行在同一套工程里加鸿蒙目标,会让原有流程变得很不稳定。单独维护鸿蒙壳工程,核心 Flutter 业务代码通过模块方式共用,两边并行迭代,改动风险和回归范围都可控。
这种方法也有代价,就是鸿蒙壳工程需要单独维护构建脚本、签名配置和 CI/CD 任务,多一层工作量。但如果项目后期确定要长期支持鸿蒙设备,单独工程带来的隔离价值是值得的。
2.2 鸿蒙工程里 Flutter 模块的标准目录结构
在鸿蒙 IDE 中创建一个应用工程后,通常会有 app 和 entry 这样的核心模块,我们需要把 Flutter 模块放进去。
工程大致是这样:
- app:应用级模块,存放公共配置、签名信息
- entry:入口模块,负责 HAP 打包、页面入口、权限声明
- flutter_module:我们引入的 Flutter 业务模块,wikipedia_api 的依赖声明和业务代码都在这层
核心思路是:鸿蒙侧的 entry 负责加载 Flutter 模块的入口页面,Flutter 模块内部自己跑一套完整的 Flutter 引擎实例。业务上,百科词条这个页面完全在 Flutter 侧实现,鸿蒙侧只需要提供一个加载入口,以及处理返回键、生命周期、网络权限等系统级事件。
这种结构的好处是,Flutter 页面里可以继续使用完整的 Flutter 导航、状态管理、动画体系,不需要被鸿蒙原生页面约束;同时鸿蒙侧的路由、权限、事件分发又能通过桥接层透传进来,两边各司其职。
2.3 依赖声明与编译参数怎么对齐
这里要特别强调一点:鸿蒙工程的标准依赖体系是以 oh-package.json5 为核心的,和 Flutter 的 pubspec.yaml 是两套体系。我们不是把 wikipedia_api 写进鸿蒙的 oh-package.json5,而是写进 Flutter 模块的 pubspec.yaml 里,然后在鸿蒙壳工程编译时,指定 Flutter 模块的构建路径。
在 Flutter 模块的 pubspec.yaml 里,核心依赖是这么声明的:
dependencies: flutter: sdk: flutter wikipedia_api: ^x.y.z版本号建议锁定到一个验证过的稳定版本。不要一上来就使用最新版本,因为新版本的 wiki 包可能产生一些行为变化,比如改了请求头、换了内部返回结构,导致后面的适配方案全部要重来。我在项目里是直接固定了版本号,同时在 pubspec.lock 里做了锁定,确保 CI 构建环境不会私自升级依赖。
编译参数上需要关注的还有两条:
- Flutter SDK 版本要选支持鸿蒙目标的版本,如果版本太老,产物在鸿蒙工程里根本挂不上去
- 鸿蒙工程的 minTarget 和 Flutter 模块的要求要匹配,比如系统 API 级别太低,或者目标设备版本太旧,可能某些网络能力不可用
我当时把鸿蒙工程的编译参数和 Flutter 模块的编译参数做了一次对照表,确保两侧的 minimum API 版本是一致的。
这里有一个很隐蔽的坑:如果鸿蒙工程的 targetSourceVersion 和 Flutter 模块的 NDK/编译工具链版本不匹配,编译会报一堆莫名其妙的链接错误,而且错误信息基本无指向性,很不友好。
2.4 网络权限与生命周期处理,少一个都白搭
wikipedia_api 的核心动作是发起网络请求,所以网络权限是绕不开的。在鸿蒙工程里,权限是在模块的 module.json 或类似配置文件里声明的,不是写在 pubspec.yaml 里。
这里我踩过第一次真机超时的坑,后面会详细讲。简单说,在接入 wiki 包前,要把网络权限先加到鸿蒙工程入口模块的配置文件里,确保页面启动时系统就已经授权。
生命周期这块也值得小心。Flutter 页面嵌入鸿蒙工程后,页面切换、应用退到后台、屏幕旋转等情况都会触发生命周期事件。wikipedia_api 的请求是异步的,如果在页面销毁后回调还在执行,轻则资源浪费,重则出现空指针闪退。
我们的做法是在 Flutter 业务层做了一层封装,页面关闭时从内存缓存里移除回调引用,同时取消未完成的请求。这里不依赖某个具体包的接口,而是统一拦截业务事件,保证“页面不可见时,网络状态不与 UI 交互”。
3. 多语言检索与摘要提取的适配实现
3.1 多语言检索的适配思路
wikipedia_api 的多语言能力,本质上是通过给百科接口传入不同的语言代码来实现的,类似lang=zh、lang=en、lang=ja这样的参数。我们自己的业务层,需要维护一个语言列表,并在用户切换语言时重新初始化 API 实例。
这里需要注意的细节是:不同语言的接口返回结构基本一致,但字符编码、标点习惯、词条排序会有差异。中文场景下,搜索词可能存在简繁混淆;英文场景下,大小写和短横线连接词会影响搜索结果;日文场景下,平假名和片假名排序规则又不一样。
我的建议是,不要把“语言切换”做成简单重启 API,而是维护一个语言上下文:
class WikiLanguageContext { final String currentLang; final WikipediaApi api; WikiLanguageContext(this.currentLang, this.api); }每次切换语言,重新创建 API 实例并清空本地缓存,这样至少能保证搜索结果不会被上一个语言的缓存污染。
在我实际验证过程中,同一词条在不同语言下返回内容长度差异很大。比如某个技术词条在英文版里可能返回三千字符的简介,在中文版里可能只有几百字符。这不算 bug,但会影响前端摘要展示的布局。适配时如果直接按固定长度截断,容易出现英文截断在半句话上、中文截断在标点习惯冲突上的问题。
所以我在摘要清洗层做了自适应截断策略:先尝试按完整句子截断,找不到句子边界时再按字符数截断,并补充省略号。这个逻辑虽然写起来只有几十行,但能明显提升不同语言场景下的阅读体验。
3.2 摘要提取与 HTML 标签清洗
公共百科接口返回的摘要,常见格式是两类:
- 纯文本摘要,直接返回字符串
- 结构化摘要,包含段落、加粗、斜体等 HTML 标签
wikipedia_api 一般会把接口返回的原始内容透传出来,不负责清洗。所以我们必须自己写解析逻辑。
我的做法是三步走。
第一步,向接口请求摘要时明确指定返回格式。能要纯文本就优先要纯文本,减少后续清洗工作量。
第二步,如果拿到的还是有标签的内容,就写一层清洗函数:
String stripHtmlTags(String html) { final tagReg = RegExp(r'<[^>]+>'); final spaceReg = RegExp(r' '); return html .replaceAll(tagReg, '') .replaceAll(spaceReg, ' ') .trim(); }这个函数简单粗暴,但对大多数富文本摘要已经够用。特殊情况下,如果内容里存在列表标签和引用标签,我会额外增加一步:把列表项转为换行,把引用段落合并为普通段落,避免页面出现一大坨粘连文本。
第三步,摘要截断要放在清洗之后。先清洗、再截断,否则截断可能把一个 HTML 标签从中间劈开,导致后续展示完全错乱。这个顺序我在一开始就写反了,导致有段时间摘要首屏显示一半加粗一半正常,非常难看。
清洗之后的数据,我会再套一层结构化模型:
class WikiSummary { final String title; final String content; final int contentLength; }业务页面只依赖模型字段,不直接依赖 API 返回的原始结构。这样后续如果 wikipedia_api 升级、字段名改变,只需要在适配层做一个字段映射,不用动 UI 代码。
3.3 请求策略:超时、重试与本地缓存
鸿蒙设备上运行第三方 Flutter 页面时,网络环境比手机要复杂得多。有的设备走 Wi-Fi,有的设备会走蜂窝网络,甚至车机上的网络环境时好时坏。wikipedia_api 底层如果用的是默认 HttpClient,默认超时时间可能长达几十秒,用户根本等不了。
我当时的请求策略是:
- 连接超时:6 秒
- 读取超时:10 秒
- 失败重试:最多 2 次,且第二次重试前加 300ms 抖动延迟
- 本地缓存:摘要数据缓存 24 小时,搜索结果缓存 2 小时
缓存这块用了一个简单的内存存储,没有引入额外数据库,避免鸿蒙工程里再增加一个原生依赖。说白了就一个 Map 加一个时间戳,页面退出时清理,进入页面时检查命中。效果很好,尤其是用户在同一个词条上来回进出时,几乎不会出现重复请求。
但缓存也有副作用,后面会专门讲一个“假数据”的坑。
3.4 真机验证:同一词条在不同语言的展示对比
适配完成后,真机验证是整个流程里最重要的一环。我当时的验证清单包括:
- 中文搜索“某开源系统”,确认返回词条标题列表有条理
- 切换英文再搜索同样的词,确认摘要展示为英文
- 日文、法文等非英文语系各选一个词,验证特殊字符编码正常
- 断网情况下,确认页面能显示缓存内容而不是空态页
- 网络恢复后,确认缓存失效并自动刷新
真机上遇到的问题和模拟器上完全不同。例如日文词条返回的摘要中,存在一些半角假名和全角标点混排的情况,清洗函数如果不做字符规范化,页面上会出现肉眼可见的间距抖动。所以我在清洗层增加了 Unicode 规范化处理,把所有全角标点统一转成半角,或者全部保留全角但保持一致性。这里没有绝对规律,核心是“保证一次会话里,同一语言的展示风格是统一的”。
这一条经验很值得记下来:鸿蒙适配的验证重点,不是验证“能不能搜”,而是验证“不同语言下搜出来的内容能不能正常展示”。数据和 UI 的一致性,往往比单条数据请求成功更有价值。
4. 真机联调时最难排查的四个问题
4.1 请求一直挂起,最后网络超时
这是我在鸿蒙真机上遇到的第一个大问题。页面能打开,请求发出去后没有响应,直到超时,日志里看不到任何异常,只有一条超时错误。
一开始我以为是 wikipedia_api 的请求写法问题,翻了不少源码,最后发现是鸿蒙工程的网络权限配置没加上。Flutter 模块运行在鸿蒙应用里时,网络权限由鸿蒙侧入口模块决定,而不是 Flutter 模块自己决定。只要入口模块没有声明 INTERNET 权限,所有网络请求都会被系统拦截。
这个问题排查的技巧是:在 Flutter 侧写一个最小网络测试页面,发起一个最简单的 POST/GET 请求,如果这个请求也超时,基本可以确认是权限或者网络栈问题。然后检查鸿蒙工程的权限声明,加上网络权限后重新打包,问题立刻消失。
4.2 返回内容出现乱码
第二次遇到的是乱码问题。某个非英文语系的词条搜索,每次返回的标题里都混着菱形乱码。
这个问题的根因不在 wikipedia_api,而在 HTTP 请求的响应编码解析。百科接口返回的内容可能是 UTF-8,但某些请求头缺失或服务端返回的 charset 声明不一致时,Dart 的字符串解析就可能按默认编码处理,导致乱码。
解决办法是:在调用 wikipedia_api 之前,不依赖包自动解析编码,而是自己发起 Ask for 时强制要求 JSON 格式,并统一按 UTF-8 解码。如果包本身没有提供编码设置入口,可以通过请求头Accept: application/json; charset=utf-8来约束服务端返回编码格式。
我当时的做法更粗暴一点:直接在业务层增加一个字符替换函数,把乱码中常见的非法字符替换为空格,然后重新规范化文本。这个做法只能作为兜底,不能用它代替正确的编码设置。
4.3 缓存导致的“假数据”问题
这个坑很有意思。接入缓存后,我测试时有几次发现搜索词改了,但页面展示的摘要还是上一个词条的内容。一开始以为是路由传参问题,排查很久,最后发现是缓存命中逻辑写错了。
原因很简单:缓存 Key 只用了语言代码,没有把词条标题拼进去。于是同一语言下,第一次搜索 A 词条,缓存了摘要;之后搜索 B 词条,因为语言相同,缓存直接命中,返回了 A 的摘要。
修复方式就是在缓存 Key 里增加词条标题和语言代码,例如zh:articleTitle:需求标题。这个问题提醒了我一个非常基础但也非常容易犯的原则:缓存的 Key 必须完整反映请求参数,任何只取部分参数的缓存设计,都会在某些边缘条件下返回错误结果。
4.4 包体积增大与首帧变慢
wikipedia_api 本身不大,但鸿蒙 Flutter 工程引入后,整体包体积会明显增大。这是 Flutter 引擎在鸿蒙平台上的通用现象,不是某一个包造成的。
如果对包体积有硬性要求,我建议做三件事:
- 开启 Flutter 产物的压缩,减少不必要的调试符号
- 代码混淆,降低字符串常量和类名体积
- 评估是否真的需要同时支持多个架构
更需要留意的反而是首帧变慢。百科页面首次打开时,wikipedia_api 的初始化、Flutter 引擎启动、网络请求握手全部集中在启动阶段,首帧渲染时间可能从 Android 上的几百毫秒涨到两秒以上。如果不处理,用户点击百科入口后面对的就是半天白屏。
我的处理是:鸿蒙壳工程启动时预加载 Flutter 引擎,百科页面打开时直接复用已加载的引擎,首帧速度大概能提升一半。同时,页面上先展示一个本地缓存的词条列表骨架,等流式数据返回后再刷新内容,让用户至少看到页面已经响应了,而不是一个毫不反馈的空白页。
下面是一个简化的排查速查表,适合在联调现场快速定位:
| 现象 | 优先检查项 | 常见原因 | 修复建议 |
|---|---|---|---|
| 请求超时 | 鸿蒙工程网络权限 | 入口模块未声明 INTERNET 权限 | 补权限后重新打包 |
| 返回乱码 | 字符编码解析 | 默认编码非 UTF-8 | 强制 JSON + UTF-8 |
| 内容不更新 | 缓存 Key 设计 | Key 未包含词条标题 | Key 改为语言+标题 |
| 首帧白屏 | Flutter 引擎启动 | 首次启动开销大 | 预加载引擎+骨架屏 |
| 平台分支错误 | 包内平台判断 | 鸿蒙系统标识特殊 | 检查源码平台分支 |
5. 一些只有踩过坑才会记住的提醒
很多人在讲鸿蒙适配的时候会强调“原生插件要重写”“桥接要重做”,但这次 wikipedia_api 的适配经验告诉我,真正容易出问题的,反而是那些看起来“应该没问题”的细节。
比如,网络权限要从鸿蒙侧入口模块配置,而不是从 Flutter 模块找;比如缓存 Key 要包含完整请求参数,不然就会出现有缓存的假数据;比如不要对“纯 Dart 包”抱有过度信心,鸿蒙 Flutter 引擎的网络栈和平台通道,和 Android、iOS 仍然存在差异。
我个人在实际操作中还有一个体会是,任何时候都要先跑通最小闭环再做 UI。我花了很长时间去调页面布局,后来发现真正影响体验的居然是请求编码和缓存策略,页面样式写得再漂亮也救不了乱码和错词条。如果重新来一次,我会更早用最小调用模型验证数据链路,再考虑视觉细节。
另外,如果你所在的团队要长期做鸿蒙适配,我建议把这次遇到的配置核查项整理成一张“鸿蒙介入清单”,每次接入新的 Flutter 三方库时,先对照清单做一轮静态排查,再决定要不要上真机联调。这样可以省掉大量反复打包、反复自测的时间成本。
这次 wikipedia_api 的适配只是整个鸿蒙化进程中的一个小环节,但它暴露出来的问题,几乎涵盖了 Flutter 三方库鸿蒙化改造的所有共性难点。把它记录下来,也算给之后接手类似改造任务的同事少铺一层雷。