Flutter 应用往 OpenHarmony 上迁移,单页面跑通其实不难,真正让人头疼的是带原生依赖的三方插件。flutter_web_auth 就是一个很典型的例子:它在 Android、iOS 上都是靠“拉一个系统浏览器上下文完成 OAuth2 授权”来工作的,到了 OpenHarmony 这边突然没有现成的原生实现,直接调authenticate()就会抛 MissingPluginException。我这次做的就是把 flutter_web_auth 适配到 OpenHarmony,同时用一个示例应用把 OAuth2 授权码模式的完整链路走通,以下是我整个从方案选型、原生实现到联调踩坑的记录。
如果你正打算把 Flutter 应用搬上 OpenHarmony,或者对“Flutter 插件怎么在非官方平台扩展”这件事感兴趣,这篇文章应该能帮你少走不少弯路。里面不只是代码,还有我为什么这么设计、哪些地方容易出错、排查顺序是什么,这些在官方文档里基本找不到。
1. 项目背景与适配方案选型
1.1 flutter_web_auth 到底在做什么
先说清楚这个插件的工作机制,因为适配的本质就是把它的工作机制从“官方支持的平台”翻译到 OpenHarmony。
flutter_web_auth 提供的是FlutterWebAuth.authenticate()接口,调用方传入一个授权页 URL 和一个回调 URL 前缀,它会调起系统级浏览器或浏览器上下文,让用户在浏览器里完成输入账号、确认授权这些操作。用户操作完成后,浏览器会重定向到回调地址,此时 URL 里通常会带上一段code参数,插件捕获到这个回调 URL 后,再把整条 URL 字符串通过 Future 返回给 Dart 层。至于拿 code 去换 token,那是业务层的事,插件本身并不关心。
这个设计有一个明显的好处:认证过程的安全敏感部分全部发生在系统浏览器里,应用自身容器拿不到用户密码,符合 OAuth2 授权码模式对“凭证不可见”的要求。但在不同平台上实现路径差异很大,Android 上靠 Chrome Custom Tabs,iOS 上走 ASWebAuthenticationSession,Windows 上则会启动默认浏览器并监听本地回环地址。Dart 层面对开发者只有一个统一接口,平台差异全部被插件内部消化掉了。
这种“Dart 只发指令,原生去跑腿”的架构,是通过 MethodChannel 实现的。Dart 侧把authenticate的 URL 参数通过通道丢给原生,原生拉起对应浏览器组件,完成后把结果字符串再传回 Dart。搞清楚这条链路,适配就成功了一半。
1.2 为什么一到 OpenHarmony 就断片
问题在于,OpenHarmony 不在 Flutter 官方支持平台列表里。虽然开源社区有人维护了基于 OpenHarmony 的 Flutter 发行版,能把 Flutter 引擎跑在 OpenHarmony 设备上,但插件的生态不会自动跟着转移。flutter_web_auth 的原生实现只覆盖了官方那几端,它在插件平台注册表里没有 OpenHarmony 对应的实现类。
所以你在 OpenHarmony 上跑一个依赖 flutter_web_auth 的 Flutter 应用,编译、安装都能过,但一调用authenticate(),MethodChannel 在原生侧找不到任何注册的 handler,最终抛 MissingPluginException。更隐蔽的问题是这个插件用的是PlatformInterface模式,它内部会有一个默认的实现类,某些版本里如果你没主动注册任何平台实现,它甚至可能在 Dart 侧直接给你一个提示说当前平台不支持。
我一开始也没意识到是“整条原生链路缺失”,还以为是环境编译问题,花了不少时间检查依赖和打包配置。后来在设备上抓 log,看到MissingPluginException才明白过来,问题出在平台通道没有原生侧接收端。
1.3 三条适配路线,我为什么选 PlatformInterface
针对这个问题,我当时列了三套可选的方案。
第一套方案是改造业务代码,弃用 flutter_web_auth。用 url_launcher 直接拉起浏览器,然后靠应用内接收回调。这个方案看起来简单,实际上问题很多:应用切到后台再回来,Activity 或 Ability 的生命周期要自己维护;如果用户中途切走,回调时机完全不可控;state 参数校验也要自己造轮子,跟重新发明一个 OAuth2 客户端没区别。我直接排除了。
第二套方案是 fork flutter_web_auth 源码,直接改 Dart 层逻辑,在里面硬编码 OpenHarmony 分支。这一套能跑通,但坏处很明显:以后官方插件升级,你要手动合代码,每一次都像做一次代码移植手术。而且把 OpenHarmony 相关代码塞进官方包,等于污染了原包,这不是一个可持续的维护姿势。
第三套方案是保持官方插件不动,在外面包一个自定义实现,注册成该插件的 OpenHarmony 平台实现。这依赖于 Flutter 插件体系的PlatformInterface机制,我们可以自己实现接口,然后在放在应用或独立适配包里统一注册。这个方案的好处是干净、内聚,不影响其它平台,升级官方插件时也不需要改动适配层。我最终选的就是这一套。
提示:选任何适配路线之前,都要先搞清楚目标插件是不是基于
PlatformInterface实现。如果是老的、直接用 MethodChannel name 全网找 handler 的插件,适配方式完全不同,不能生搬硬套。
2. 工程化准备与适配工程搭建
2.1 环境与版本对齐
先把工具链准备好。我这边用的是一套 OpenHarmony SDK 配套的开发环境和某个开源社区维护的 Flutter OpenHarmony 发行版,两者版本必须严格对应。这里有个常见的坑:Flutter 官方版本和 OpenHarmony 发行版版本不完全一致,你会发现编译出的 so 库对不上,或者 Dart AOT 运行时直接起不来。
具体来说,我建议把你准备用的 Flutter 版本固定在 3.x 的某个稳定点,然后在 OpenHarmony 发行版仓库里找到对应的 tag 来用,不要随手拉最新。另外 OpenHarmony SDK 的 API 版本也有讲究,我这边当时用的是 API 9 以上的版本,Web 组件相关能力已经比较完整,如果 SDK 版本太低,后面 Web 组件的很多接口就调不了。
DevEco Studio 和 Flutter 命令行工具要共存,但构建动作要分开理解:Dart 侧代码和 Flutter 引擎产物由 Flutter 工具链产出,OpenHarmony 的 HAP 打包则由 DevEco 侧完成。实际联调时我是在 DevEco 里跑 HAP,然后把 Flutter 引擎的产物作为依赖带入的,顺序不要搞反。
2.2 适配包工程结构
我建了一个独立的适配包,取名flutter_web_auth_ohos,用它来承载所有 OpenHarmony 专属逻辑。目录结构大致如下:
flutter_web_auth_ohos/ ├── lib/ │ └── src/ │ └── flutter_web_auth_ohos_plugin.dart # Dart 层注册与实现 ├── ohos/ │ ├── entry/ │ │ └── src/main/ │ │ ├── ets/ │ │ │ ├── AuthAbility.ets # 授权页 Ability │ │ │ ├── AuthPage.ets # 授权页 Web 组件宿主 │ │ │ └── AuthChannel.ets # MethodChannel 原生 handler │ │ └── module.json5 # 模块配置 │ └── ... └── example/ └── ...这个结构参照了 Flutter 插件标准的“平台包在外、Dart 接口在内”的风格。Dart 层只负责注册 PlatformInterface 的实例,真正的授权逻辑全部放在ohos/目录下,由 ArkTS 代码实现。
这么拆分有一个额外好处:独立成包后,示例应用可以直接通过 Git 依赖引用它,不需要把适配代码复制进每个用到它的工程。而且适配层和业务层隔离,以后就算 OpenHarmony 进度变了,比如 Ability 换成 Stage 模型新写法,修改范围也锁定在原生目录里。
2.3 先打通一条能“互通”的 MethodChannel
在写复杂的 OAuth2 逻辑之前,我建议先做最小连通性验证:Dart 发一个 ping,原生回一个 pong。这一步能提前暴露 channel 名称不一致、handler 没注册、二进制不匹配等环境级问题。
flutter_web_auth 内部使用的通道名是固定的,叫com.linusu.flutter_web_auth,这是插件源码里写死的。我们适配的时候,原生侧必须注册一个同名通道,否则 Dart 那边永远收不到结果。这个细节我差点忽略,因为一般自己写插件时会统一命名,但这里是被动适配别人的插件,通道名必须以对方源码为准。
ArkTS 侧注册通道的方式大致如下:
import { rpc } from '@kit.RemoteCommunicationKit'; // 在 Flutter 引擎初始化后,注册 MethodChannel handler let channel = new rpc.MethodChannel(engine, "com.linusu.flutter_web_auth"); channel.setMethodCallHandler((call) => { if (call.method === "authenticate") { // 执行授权流程 let url = call.arguments.url; let redirectPrefix = call.arguments.redirectPrefix; // ... } });这段代码的关键是 channel 名称必须和 Dart 侧一致,其次才是方法名。先跑通 ping/pong 再往里填真正的授权逻辑,后面排查问题时就能少一个变量。
注意:如果你发现原生 handler 有注册但 Dart 还是报 MissingPluginException,请优先检查 engine 实例是否和页面实际使用的 engine 是同一个。有的项目里多次调用
createFlutterEngine(),结果通道注册到了别的引擎上,这种问题往往藏得很深。
3. 原生侧核心实现:用 ArkTS Web 组件撑起授权页
3.1 授权链路时序设计
接下来是整个适配的核心:原生侧怎么把“浏览器上下文”这件事在 OpenHarmony 上复刻出来。我的做法是用 ArkTS 的 Web 组件直接渲染授权页面,整个流程分成五个阶段:
- Dart 层调用
authenticate(),把授权 URL 和回调前缀通过通道传给原生。 - 原生侧启动一个全屏的授权页 Ability,Web 组件加载授权 URL。
- 用户在 Web 页面里完成登录与授权操作。
- OAuth2 服务端重定向到一个以
redirectPrefix开头的 URL,里面携带code。 - 原生侧通过
onLoadIntercept捕获这个回调 URL,解析参数,把结果回传给 Dart。
这个流程和 Android 上的 Chrome Custom Tabs 思路一致,只是没有用独立的浏览器产品,而是用应用内嵌的 Web 组件。因为是在应用内完成的,所以不需要跳系统浏览器,也省去了“从浏览器再跳回应用”的繁琐步骤。
要说清楚的是,这里存在一个安全取舍:应用内嵌 Web 组件意味着用户的输入发生在应用进程可见的环境里。不过对适配和示例应用来说这是合理的,因为很多 OpenHarmony 设备上的系统浏览器组件并不能被第三方应用随意拉起特定页面,尤其是没有浏览器应用的精简设备。
3.2 授权页 Ability 与 Web 组件搭建
授权页原生侧是一个单独的页面,我用 ArkTS 的@Entry组件来承载。页面里只有一个 Web 组件,配置上要特别留意几个开关。
@Entry @Component struct AuthPage { controller: WebController = new WebController(); @State authUrl: string = ""; build() { Column() { Web({ src: this.authUrl, controller: this.controller }) .javaScriptAccess(true) .domStorageAccess(true) .onLoadIntercept((event) => { // 拦截并检查是否为回调 URL if (this.isRedirectCallback(event.request.getRequestUrl())) { this.handleCallback(event.request.getRequestUrl()); return true; } return false; }) .onPageEnd((event) => { console.info("Auth page load finished: " + event.url); }) } } }javaScriptAccess(true)和domStorageAccess(true)是我开了以后才不让授权页白屏的。很多 OAuth2 登录页都要操作 localStorage 存临时状态,如果不允许 DOM 存储,页面可能一直转圈。
这段代码里还有一个点值得解释:onLoadIntercept返回 true 表示“这个请求我拦截掉了,Web 组件不用继续加载”。当拦截到回调 URL 时是要停止加载的,因为用户已经拿到了授权码,继续加载回调地址毫无意义。返回 true 能避免 Web 组件跳到乱七八糟的中间页。
3.3 回调 URL 捕获与结果回传
回调 URL 的捕获是整个适配成败的关键。OAuth2 服务端在用户授权后会重定向到我们预先注册的redirect_uri,通常是myapp://auth/callback这样的自定义 Scheme。Web 组件在收到这个跳转时,加载的 URL 已经不是 http/https 了,而是一个应用私有 Scheme 的地址。
我在isRedirectCallback里做的事,本质上是前缀匹配:判断当前 URL 是否以redirectPrefix开头。为了调试方便,我没有直接返回布尔值,而是先在回调里把完整 URL 打出来看一眼。
捕获到回调 URL 后,要做的不是把整个 URL 原样丢回 Dart 就算了,而是要先解析出code,最好顺手校验一下state。这里有个经验:原生层不要承担太多业务逻辑,解析 token 和后续换取 access_token 的请求都放到 Dart 层做,原生层只需要完成“拿到 URL、判断是回调、原样回传”这三件事。
回传给 Dart 的方法是对MethodChannel的调用结果做 resolve:
let result = new rpc.Result(); result.success(url); // url 是捕获到的完整回调地址Dart 侧拿到的就是一个字符串 URL,后面怎么拆参数就是业务层的事了。
3.4 Cookie 与登录态收尾
授权页关闭后,有一件事很容易被忽略:Cookie。Web 组件默认会保留会话信息,也就是说第一次授权成功后,第二次再调authenticate()时,Web 组件可能还带着上一次的登录态,用户会直接看到“确认授权”页而不是登录页。
这在实际联调时很坑,我开始以为每次拉起都是全新上下文,后来发现用户已经登录过了,怎么调都是直接回调。直到我手动清了 Cookie 才恢复登录页。
所以我在适配层的clean()方法里加入了清除 Cookie 的逻辑,对应刷新 Web 组件的 Cookie 管理器:
import { webview } from '@kit.ArkWeb'; function clearWebAuthCookies(): void { let cookieMgr = webview.WebCookieManager.getCookieManager(); cookieMgr.clearAllCookiesSync(); }这个清理动作放在“用户退出登录”或“开始新一轮授权之前”执行。但也要注意别一刀切清掉所有 Cookie,如果应用本身还有内嵌 WebView 场景,消费方可能不愿意把全部 Cookie 清掉。我这边因为示例应用只有授权这一个场景,所以简化处理了。
4. 示例应用开发与 OAuth2 实战接入
4.1 示例应用的功能与流程
适配层做完,接下来就是用真实业务去检验。我做了一个示例应用,模拟“某协作文档服务客户端”,不认识真实产品,纯粹用本地 mock 的 OAuth2 服务来验证链路。
这个示例应用要覆盖的场景有四个:发起登录、接收回调、换 token、展示用户信息。界面只有两个状态:未登录时显示“立即登录”按钮,已登录时显示用户名和“退出登录”。就这么简单的应用,恰恰能把 flutter_web_auth 的核心能力完整串一遍。
登录流程如下:
点击登录 -> 组装授权 URL -> 调用 flutter_web_auth 的 authenticate() -> 原生拉起授权页 -> 用户输入用户名密码并授权 -> 服务端重定向到自定义 Scheme 回调 -> 原生捕获回调 URL 并返回 Dart -> Dart 解析 code + 校验 state -> 使用 code 调 token 接口 -> 拿到 access_token -> 调用用户信息接口 -> 更新界面整个链路里,前五步是适配层负责的,后三步是业务层写的。如果适配层没问题,业务层的代码跟你在 Android 上写的基本没有差别。
4.2 授权码模式完整实现
OAuth2 授权码模式在业务层的核心调用,我直接贴简化后的 Dart 代码。
import 'package:flutter_web_auth/flutter_web_auth.dart'; Future<String> startOAuthLogin() async { final state = generateState(); // 随机字符串,防止 CSRF final redirectUri = "myapp://auth/callback"; final authUrl = Uri.https( "auth.example.com", // mock 服务域名 "/oauth2/authorize", { "response_type": "code", "client_id": "demo_client", "redirect_uri": redirectUri, "scope": "read_profile", "state": state, }, ).toString(); final resultUrl = await FlutterWebAuth.authenticate( url: authUrl, redirectPrefix: redirectUri, ); // 解析回调 URL final uri = Uri.parse(resultUrl); final code = uri.queryParameters['code']; final returnedState = uri.queryParameters['state']; if (returnedState != state) { throw Exception("state mismatch, possible CSRF"); } return code!; }这里的redirectPrefix参数很关键,它的作用就是告诉原生层“目标 URL 长什么样算回调”。我在适配层里就是拿它做前缀匹配的。需要注意一点,redirectPrefix要传完整的前缀,比如myapp://auth/callback,不要只传myapp://,否则一旦有其它动静也会被当成回调。
拿到 code 以后,还要继续走换取 token 的逻辑:
Future<TokenResult> exchangeToken(String code) async { final resp = await http.post( Uri.https("auth.example.com", "/oauth2/token"), body: { "grant_type": "authorization_code", "client_id": "demo_client", "client_secret": "demo_secret", "code": code, "redirect_uri": "myapp://auth/callback", }, ); // 解析 json,拿到 access_token / refresh_token return TokenResult.fromJson(jsonDecode(resp.body)); }整个过程中,Dart 层唯一需要依赖 flutter_web_auth 的地方就是那一个authenticate()调用。后面所有 HTTP 请求都是普通网络操作,这也是这个插件设计得好的地方:它把最脏的平台活干完了,业务侧保持干净。
4.3 配置清单:module.json5 与 URL Scheme
在 OpenHarmony 上,自定义 Scheme 的回调不是一个松散的字符串,而是需要正式声明的能力。如果你没有在模块配置里声明这个 Scheme,当 Web 组件尝试跳转到myapp://auth/callback时,OpenHarmony 根本不知道该把这个跳转交给哪个应用处理。
我在module.json5里重点配置了几块。首先是授权页 Ability 的注册,如果使用 Stage 模型,要在abilities数组里显式声明AuthAbility,并且把它的启动模式设为单例或独立,避免重复拉起。
然后是 URL Scheme 的声明,在skills里配置起来:
{ "skills": [ { "actions": ["action.system.home"], "entities": ["entity.system.home"] } ] }这一块主要解决的是首页入口问题。真正让自定义 Scheme 生效的是在模块配置里的uris字段,要允许myapp这个 scheme 被本应用接收。这块配置因为和具体的 OpenHarmony 版本 API 有关系,建议以你当时使用的 SDK 文档为准,不过核心逻辑是固定的:要把你想接收的 scheme 和 host 全部列进去。
我没有把真实产品的域名或 scheme 写进示例代码,实际工程里用你自己的域名替换即可,但要注意域名必须是 OAuth2 服务端注册过的一致,否则服务端会把 redirect_uri 直接拒绝掉。
4.4 联调记录与效果验证
在真机上联调的时候,我第一次跑通并没有想象中顺利。日志前几行看到的不是回调成功,而是 Web 组件加载授权页时出现的大量网络请求日志。我把这些日志等级调低以后才能看到关键信息。
第一次点登录按钮,授权页正常拉起,输入模拟账号密码后点了授权,然后整个授权页直接消失了。我心里一紧,赶紧看日志。发现问题很简单:服务端跳转到了myapp://auth/callback,但 Web 组件的加载被成功拦截,结果也通过 channel 回传了,只是 Dart 解析完后发现state对不上。
我排查后发现是我的generateState()写得不严谨:我用了一个Random实例,但忘了随机源在每次调用时会重置,导致两个 state 字符串一样。当然不是每次都一样,但一旦命中,服务端请求一次是带着新 state,回调返回的却是旧 state,校验就过不去了。这种问题在 Android 上也会发生,只是我平时没注意,因为以前用的库内部做了处理。
修正state生成逻辑后,第二次联调链路就通了:登录按钮变成“欢迎,demo_user”,日志里能清晰看到从 authorize 到 token 再到 userinfo 三个接口的调用记录。
5. 常见问题与排查实录
5.1 问题速查表
我在这次适配中遇到过的问题,整理成了一张速查表,各位在 OpenHarmony 上接认证流程时可以对照排查。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 调用 authenticate 直接抛 MissingPluginException | MethodChannel 名称不一致或原生 handler 未注册 | 确认通道名为com.linusu.flutter_web_auth,确认通道注册在正确的 engine 上 |
| 授权页白屏 | Web 组件没开 JS 或 DOM 存储 | 加上.javaScriptAccess(true)和.domStorageAccess(true) |
| 授权完成后 App 没有响应 | 回调 URL 没有被拦截 | 在onLoadIntercept里打印所有加载 URL,检查是否有子页面跳转被忽略 |
| 每次登录都是上次的已登录状态 | Cookie 残留 | 在重新发起授权前调用clearAllCookiesSync() |
| 服务端提示 redirect_uri 不合法 | 配置的 scheme/host 和注册不一致 | 检查 module.json5 的 uris 声明,确认 OAuth2 服务端注册的地址完全一致 |
| 授权页拉起后闪退 | Ability 生命周期管理问题 | 检查 Ability 的启动模式和页面返回逻辑,避免多次拉起同一个页面 |
| token 换完以后数据不刷新 | 界面状态没更新 | 这是业务层问题,确认异步调用结束后 UI 是在主线程更新 |
5.2 两个容易反复踩的点
第一个坑是 Web 组件的生命周期。授权页 Ability 如果被系统回收,比如应用在后台呆久了,回跳过来时可能找不到之前的页面实例。我在真机上就遇到过一次,用户在授权页停留较久,授权成功后应用直接从启动页回来了,整个链路断了。解决思路是在 Ability 的onNewWant里补充场景切换处理,保证回跳的时候如果 Ability 已经存在,就把它拉起来而不是新建。
第二个坑是日志打印太少。我开始对回调拦截逻辑不够自信,因为onLoadIntercept并不一定能拦截到所有跳转,尤其是 OAuth2 服务端做了多级重定向以后,真正的回调 URL 可能藏在第三次跳转里。建议在拦截回调里不急着做匹配,先无条件打印每一次的 URL,等你亲眼看到完整回调链了,再收紧匹配条件。我后面适应了这种调试方式,效率明显提升。
还有一个我觉得值得养成的习惯:所有和外部服务相关的配置都放到一个单独的配置文件里,不要在代码里到处硬编码。示例应用的 client_id、redirect_uri、scope 全是集中配置的,换环境时只改一个文件,排查问题也更轻松。
注意:授权页属于应用内的敏感页面,务必在授权流程开始前明确告知用户正在跳转第三方认证服务,并且不要在代码里保存用户密码、验证码等敏感输入,这些数据只应该流经 OAuth2 服务端自己的页面。
最后说点个人体会
适配 flutter_web_auth 这件事,表面上是在写代码,本质上是在把“这个插件在其它平台上依赖了原生什么能力”这个问题彻底想透。很多 Flutter 开发者在官方支持的平台上写业务写习惯了,反而不会去关心插件内部是怎么注册通道、怎么拉起原生页面的,但一到 OpenHarmony 这样的非官方平台,这些隐藏知识就会全部变成刚需。
我个人最大的收获不是把授权流程跑通,而是建立了一套“插件适配方法论”:先读源码弄清通道名和接口语义,再在原生侧最小可运行验证,最后才往里面填完整逻辑。这套方法我后续适配其它三方库时也在用,比如社交登录、支付回调这类同样需要拉原生活动再回跳的插件,思路完全复用得上。
如果你正在做类似的事情,最后再分享一个经验:真机上调试时,一定先把原生侧和 Dart 侧两边的日志同时打开。OpenHarmony 设备和电脑之间如果用日志工具连接,原生日志会和 Flutter 日志混在一起输出,养成用英文前缀区分来源的习惯,排查速度会快很多。适配工作没有太多捷径,但把每一层的关键日志提前打好,能让你每一步都走得踏实。