拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OpenHarmony上集成Flutter三方UI库:pin_code_fields实现应用锁全流程

OpenHarmony上集成Flutter三方UI库:pin_code_fields实现应用锁全流程

最近在 OpenHarmony 上做 Flutter 应用,最绕不开的一件事就是:三方库能不能用。生态里绝大多数 Flutter 包都是按 Android/iOS 的原生能力写的,真正落到鸿蒙工程里,常常要么编译不过,要么某个平台能力悄悄失效。我这次做的是应用锁功能,要在应用启动和回前台时弹出一个 PIN 码输入层,选型时直接盯上了 Flutter 社区里很成熟的 pin_code_fields 库。

一开始也犹豫过,这个库在 Android 上跑得再顺手,到 OpenHarmony 上是不是还得改源码、补桥接?结果整个流程走下来,比预想中顺,但中间确实有几步容易被文档带偏。这篇文章把我从工程搭建、依赖分析、真机验证到应用锁完整实现的思路和踩坑记录都写出来,给准备在 OpenHarmony 上接 Flutter 三方 UI 库或做类似锁屏功能的朋友一个参考。

1. 应用锁需求为什么落在这个组合上

1.1 应用锁的真实使用场景

先说需求本身。应用锁这个东西在很多设备上都存在,但设计初衷差异很大。手机端最常见的是隐私保护,打开某个应用前必须输入 PIN 码或者验证指纹;而在电视、平板这类共享设备上,应用锁更多是家长控制场景,比如儿童模式下只允许打开几个教育类应用,其他应用全部锁住,进入需要家长输入 PIN 码。

这些场景有一个共同点:锁屏界面必须在极短时间内让用户理解“我需要输入什么”,而且输入体验要顺畅。如果 PIN 码输入框做得很糙,动画生硬,错误反馈不明确,用户第一反应就是应用有 bug,而不是自己输错了。所以 UI 组件的成熟度非常关键。

我这边最终要覆盖的场景还包含电视端,遥控器操作和焦点管理比触摸屏更挑剔,更不能拿一个简单的 TextField 拼一下就当完成。选一个现成、稳定、自由度高的 PIN 输入组件,是很自然的选择。

1.2 为什么挑中 pin_code_fields

Flutter 生态里 PIN 码输入的库不算多,pin_code_fields 算是最流行的一个。它把常用的交互细节都封装好了:字符显隐切换、输入完成回调、错误动画、光标闪烁、粘贴拦截、主题定制,几乎覆盖应用锁需要的全部交互。而且它是纯 Dart 实现的 UI 组件,底层没有直接依赖 Android 的 View 体系或 iOS 的 UIKit,这对 OpenHarmony 适配来说是个极大利好。

我也对比过 handwrite 方案。自己写其实也不难,无非是几个 TextField 拼在一起,但真正做起来会发现细节非常多:焦点切换、退格删除回退、粘贴文本的拆分、错误状态下的抖动动画、暗色模式配色,一套全做下来少说也要两三天。而 pin_code_fields 这些能力开箱即用,同时保留了足够的参数去覆盖电视端的特殊需求,性价比明显更高。

1.3 适配风险的第一判断

在 OpenHarmony 项目里引入任何 Flutter 三方库,第一个问题永远是:它有没有碰平台特性。我的判断方法是先看包的依赖树和目录结构。如果一个库的lib目录下全是.dart文件,没有android/也没有ios/目录,pubspec 里没有引入path_provider这类带原生逻辑的传递依赖,那么它跑到 OpenHarmony 上的风险就非常低,基本只取决于 Flutter 引擎移植版对 UI 能力的支持完整度。

pin_code_fields 恰好就符合这个特征。所以从选型那一刻起,我心里就清楚,真正的工作重心不在“改动这个库”,而在“把 OpenHarmony 工程环境搭对,然后验证 UI 与输入法链路”。

2. OpenHarmony 下的 Flutter 工程搭建实录

2.1 拿对 SDK 分支,省掉一半适配时间

OpenHarmony 上跑 Flutter,不能用官方 pub.dev 下载的那个 Flutter SDK,必须使用 OpenHarmony SIG 维护的 flutter_flutter 分支。这个分支把 Flutter 引擎的渲染、事件分发、文本输入、平台通道等能力对齐到了鸿蒙系统上。我第一次直接用官方 flutter 创建工程再往 OpenHarmony 设备上跑,结果连编译都过不了,原因就是 SDK 能力不匹配。

正确做法是先拉取对应分支:

git clone https://gitee.com/openharmony-sig/flutter_flutter.git cd flutter_flutter # 切换到对应 OpenHarmony 版本的 release 分支 git checkout OpenHarmony-4.0-Release export PATH=$(pwd)/bin:$PATH flutter --version

这里有个容易踩的坑:分支版本必须和开发板的 OpenHarmony 系统版本对齐。比如设备系统是 OpenHarmony 4.0,就尽量用 4.0 对应的 Flutter 分支。版本跨太大,可能出现引擎层与系统底层接口不匹配,表现为页面能起但触摸无响应,或者输入法一直弹不出来。这类问题查起来非常痛苦,因为报错信息往往不明显。

2.2 工程生成的两种路径

SDK 就绪后,创建工程的方式有两种。

如果你的 flutter_flutter 分支支持 OpenHarmony 平台生成,可以一步到位:

flutter create --platforms=ohos --org com.example app_lock_demo

生成后项目根目录会出现ohos/子目录,里面是鸿蒙侧工程结构,包括 entry 模块、Flutter 模块引用、oh-package.json5 等。

如果分支版本较老,不支持--platforms=ohos参数,就先用常规命令生成工程,再把 OpenHarmony 侧的 ohos 目录从一个官方示例工程中拷贝过来,手动修改包名和应用名。这条路稍微繁琐,但原理上是一致的:ohos 目录就是鸿蒙应用壳,Flutter 代码作为引擎加载到鸿蒙的 Ability 里。我个人更推荐前一种方式,能省掉后续很多手工配置。

工程创建完成后,在pubspec.yaml里添加 pin_code_fields 依赖:

dependencies: flutter: sdk: flutter pin_code_fields: ^8.0.1

然后执行flutter pub get。到这一步都不需要额外配置。

2.3 DevEco 侧联编与签名的细节

Flutter 工程在 OpenHarmony 上构建,最终产物是 HAP 包,这一步通常要靠 DevEco Studio 完成,因为它还承担了签名、设备连接、日志查看这些工作。我实践下来的做法是:直接用 DevEco Studio 打开项目根目录,让它识别整个工程,然后配置自动签名。

签名这个环节在 OpenHarmony 开发里特别容易被忽略。点 Run 之前必须先给应用配置调试证书,不然会报签名相关的错误,提示信息出现在 DevEco 的 Build 窗口里。自动签名需要登录华为账号并开通对应设备的调试权限。第一次配置稍花时间,配置好后基本上不用再管。

完成签名后可以直接点 Run,也可以在终端执行flutter build hap构建 HAP 产物,再通过 DevEco 的设备管理器安装到开发板上。我个人习惯用 Run 调试,因为可以实时看 Flutter 的 debug 日志,定位问题效率更高。

3. pin_code_fields 依赖分析与运行验证

3.1 先看这个库是不是“纯 Dart”

拿到依赖之后,第一步不要急着写界面,先确认这个库内部到底引用了什么。

flutter pub deps --style=compact

从输出可以看到 pin_code_fields 的依赖基本只有 flutter 和 flutter_localizations,没有 path_provider、shared_preferences 这类常见原生插件。再进到 pub 缓存的包目录里看一下结构:

find ~/.pub-cache -path "*pin_code_fields-*" -maxdepth 3 -type d

进入对应目录后,里面只有lib/、example/、pubspec.yaml,没有android/、ios/目录,也没有用到 MethodChannel 或 EventChannel 的代码。这就说明它所有 UI 能力都构建在 Flutter 引擎的 canvas、文本输入、动画系统之上。而这些能力恰恰是 OpenHarmony Flutter 分支重点移植过的部分。只要引擎移植质量稳定,这个库在鸿蒙上就能正常跑。

这个“先查纯不纯”的步骤我建议每个库都过一遍,尤其是遇到编译错误或者运行异常的时候,能帮你在第一时间判断问题出在库本身还是引擎层。

3.2 最小验证页跑起来

确认依赖链路后,我写了一个最小验证页面,不掺任何业务逻辑,就一个 PIN 输入框:

import 'package:flutter/material.dart'; import 'package:pin_code_fields/pin_code_fields.dart'; class PinDemoPage extends StatefulWidget { const PinDemoPage({super.key}); @override State<PinDemoPage> createState() => _PinDemoPageState(); } class _PinDemoPageState extends State<PinDemoPage> { @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF12141C), body: Center( child: PinCodeTextField( appContext: context, length: 6, obscureText: true, animationType: AnimationType.fade, pinTheme: PinTheme( shape: PinCodeFieldShape.box, fieldHeight: 52, fieldWidth: 46, activeColor: Colors.blueAccent, selectedColor: Colors.blueAccent, inactiveColor: Colors.grey.shade400, borderRadius: BorderRadius.circular(12), ), onCompleted: (pin) { debugPrint('PIN completed: $pin'); }, ), ), ); } }

这段代码直接放到 OpenHarmony 真机上运行,我验证下来 UI 渲染正常、字符隐藏正常、输入完成回调正常。尤其让我放心的是光标闪烁和激活态边框切换,跟 Android 上的表现几乎一致,说明引擎移植对 text input 和 text field 相关逻辑的处理已经比较完整。

3.3 哪些系统能力仍然绕不开

虽然库本身是纯 Dart,但运行过程中还会间接依赖几个系统能力:

  • 文本输入面板,也就是软键盘的弹出和关闭,这需要 Flutter 引擎通过平台通道和鸿蒙的输入法框架通信。
  • 触觉反馈,例如错误时的震动,依赖鸿蒙的震动服务能力。
  • 剪贴板读取,如果启用粘贴能力,会走引擎层的剪贴板通道。

这几个能力在我实测的 OpenHarmony 4.0 分支上都可用。但如果你的目标系统版本较老,建议单独验证一下输入法通道。最简单的方式就是让输入框聚焦,看软键盘能否弹出,焦点是否正常。这一步没问题,pin_code_fields 的大部分交互就都能支撑起来。

4. 应用锁核心功能实现

4.1 锁定层用 Stack 覆盖,而不是路由跳转

应用锁功能的第一设计决策,是锁定层怎么呈现。最容易想到的做法是解锁时Navigator.push一个锁屏页面,验证成功后 pop 回业务页。这个方案有一个致命问题:路由栈里锁屏页下面是业务页,如果用户按返回键,可能绕回业务内容,锁就形同虚设。

我采用的是更稳妥的 Stack 覆盖方案。最外层是一个AppLockGate组件,内部用 Stack 承载业务层和锁定层。锁定时锁定层铺满全屏并挡住所有触摸,解锁后锁定层直接消失,业务层原封不动。这样业务状态不会因为锁定而被销毁,解锁后的体验是“无缝回到刚才的页面”,而不是重建页面。

Stack( children: [ widget.child, // 真正的业务应用 if (_locked) const Positioned.fill( child: LockScreen(), ), ], )

这个结构同时在视觉上天然防绕过。因为锁定层覆盖在上面,任何返回手势或按键都优先落在它身上,业务页完全接触不到。

4.2 AppLockGate 与生命周期监听

锁定状态的变化点集中在两个时机:应用冷启动,和应用从后台切回前台。冷启动时_locked初始值直接设成 true,保证一进来就锁。后台切回前台则需要监听应用生命周期。

class AppLockGate extends StatefulWidget { const AppLockGate({ super.key, required this.child, required this.correctPin, }); final Widget child; final String correctPin; @override State<AppLockGate> createState() => _AppLockGateState(); } class _AppLockGateState extends State<AppLockGate> with WidgetsBindingObserver { bool _locked = true; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed && !_locked) { setState(() => _locked = true); } } void _unlock(String pin) { if (pin == widget.correctPin) { setState(() => _locked = false); } } void _lockNow() { if (!_locked) { setState(() => _locked = true); } } @override Widget build(BuildContext context) { return Stack( children: [ widget.child, if (_locked) Positioned.fill( child: LockScreen( correctPin: widget.correctPin, onUnlock: _unlock, ), ), ], ); } }

注意一个细节:didChangeAppLifecycleState里触发_lockNow时要判断当前锁状态,避免已经处于锁定状态时还做无谓的 setState,减少无意义的 rebuild。这个习惯在锁屏这种高频生命周期切换场景里尤其重要。

4.3 PIN 校验和错误反馈

LockScreen 内部就是 pin_code_fields 发挥价值的地方。校验逻辑分两条路径:正确就回调onUnlock,把外层_locked置为 false;错误就触发错误动画并清空输入框。

pin_code_fields 提供了一个errorAnimationController,专门用来做错误时的抖动动画。用法是先创建一个 AnimationController,在验证失败时调用 forward 触发动画。

class LockScreen extends StatefulWidget { const LockScreen({ super.key, required this.correctPin, required this.onUnlock, }); final String correctPin; final void Function(String pin) onUnlock; @override State<LockScreen> createState() => _LockScreenState(); } class _LockScreenState extends State<LockScreen> with SingleTickerProviderStateMixin { late final AnimationController _errorController; final TextEditingController _pinController = TextEditingController(); bool _hasError = false; @override void initState() { super.initState(); _errorController = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), )..addStatusListener((status) { if (status == AnimationStatus.completed) { _errorController.reset(); } }); } @override void dispose() { _errorController.dispose(); _pinController.dispose(); super.dispose(); } void _verifyPin(String pin) { if (pin == widget.correctPin) { widget.onUnlock(pin); } else { setState(() => _hasError = true); _pinController.clear(); _errorController.forward(); } } @override Widget build(BuildContext context) { return Material( color: const Color(0xFF12141C), child: SafeArea( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.lock_outline, size: 48, color: Colors.white), const SizedBox(height: 12), const Text( '应用已锁定', style: TextStyle( fontSize: 18, color: Colors.white, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 28), PinCodeTextField( appContext: context, length: 6, controller: _pinController, focusNode: FocusNode(), obscureText: true, obscuringCharacter: '●', autoDismissKeyboard: true, animationType: AnimationType.fade, errorAnimationController: _errorController, keyboardType: TextInputType.number, pinTheme: PinTheme( shape: PinCodeFieldShape.box, fieldHeight: 52, fieldWidth: 46, activeColor: Colors.blueAccent, selectedColor: Colors.blueAccent, inactiveColor: Colors.grey.shade600, activeFillColor: const Color(0xFF1F2937), selectedFillColor: const Color(0xFF1F2937), inactiveFillColor: const Color(0xFF1F2937), borderRadius: BorderRadius.circular(12), ), onChanged: (value) { if (_hasError && value.isNotEmpty) { setState(() => _hasError = false); } }, onCompleted: _verifyPin, ), ], ), ), ), ); } }

这里有个比较关键的体验细节:错误状态下用户开始重新输入第一个字符时,要立即清除错误状态。否则界面一直停留在红色错误态,用户会以为输入没生效。我通过onChanged里判断_hasError并主动 reset 来解决。

4.4 与业务入口的整合结构

整合方式是把 AppLockGate 放在 MaterialApp 外层,业务应用作为 child 传入:

void main() { runApp( AppLockGate( correctPin: '123456', child: MaterialApp( title: 'App Lock Demo', home: HomePage(), ), ), ); }

这个结构性决策有讲究。如果把 AppLockGate 放在 MaterialApp 里面,比如作为某个 Home 的包装,那么当业务路由跳转时,锁定层只能覆盖当前页面,其他页面就成了漏网之鱼。放在 MaterialApp 外层后,Stack 里的 child 是整个应用,锁定时所有页面、所有路由都被同一层遮住,无死角。

实际测试里,我从业务页按 Home 键回到桌面,再点击应用图标回前台,锁定层立即出现,输入正确 PIN 后返回原业务页。整个过程页面状态保持完整,也就是 Stack 覆盖方式带来的收益。

5. 实测中的输入、焦点与电视端问题

5.1 锁屏页不自动弹键盘

真机测试第一轮就发现一个问题:锁屏页显示后软键盘不会自动弹出,必须手动点击输入框才唤起。这在应用锁场景里很影响体验,用户打开应用看到锁屏,往往会下意识直接输密码,如果键盘没弹出来,第一反应通常是“死机了”。

解决办法是在首次构建完成后主动请求焦点:

@override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { FocusScope.of(context).requestFocus(_focusNode); } }); }

addPostFrameCallback是因为首帧 frame 尚未完成时,focus 系统可能还没准备好,直接 requestFocus 有时候会无效。在 OpenHarmony 真机上,我试了 initState 里直接执行,确实不稳定,改成 postFrameCallback 后每次都能正常弹出键盘。

还需要给 PinCodeTextField 显式传入 focusNode,并记得在 dispose 中释放。

5.2 数字键盘类型的覆盖

应用锁的 PIN 默认是纯数字,所以键盘类型要明确指定为 number:

keyboardType: TextInputType.number,

这一步在 Android 上默认行为可能没问题,但在不同 OpenHarmony 设备上,有的默认键盘类型会带出英文字母,影响输入效率。明确指定后,数字键盘的唤起路径就稳定了。

另外一个细节是textInputAction,我设置成TextInputAction.done,配合autoDismissKeyboard: true,用户输完 6 位后希望键盘能自动收起,进入校验流程,而不是还要手动关键盘。

5.3 电视端遥控器操作与焦点管理

如果目标设备里有电视盒子,那就必须考虑遥控器方向键和确定键的交互。pin_code_fields 的底层是 TextField,而 TextField 天然支持焦点导航,所以在标准 Focus 体系下,遥控器上下左右切换字段、按确定聚焦输入是能正常工作的。但在 OpenHarmony 电视端,部分输入法框架对软件键盘支持有限,遥控器不是每个模式都能唤起数字键盘。

我处理的思路是双通道:默认走文本输入,遥控器按键事件里额外监听数字键,直接把KeyEvent的字符 append 进输入控制器。这样即使软键盘没有弹出,用户也能用遥控器数字键完成输入。这一步和 pin_code_fields 本身没有冲突,因为它只是从 controller 里取值。

电视端还有一个常见问题:初次进入锁屏页时,焦点可能落在其他可聚焦组件上。解决办法是给锁定页外面套一个FocusScope,并设置autofocus: true,让整个锁屏区域成为初始焦点作用域,确保遥控器按键事件能正确到达输入框。

6. PIN 存储与安全扩展

6.1 PIN 不应该明文存

到这一步,应用锁的基本功能已经完整。接下来必须考虑 PIN 码本身的存储问题。如果 PIN 直接以字符串形式写入本地文件,别人拿到设备文件系统权限后就能直接读取,应用锁就失去了意义。

在 OpenHarmony 上,常规的安全做法是利用系统提供的密钥能力做加密存储。OpenHarmony 提供 HUKS 能力,可以生成非对称密钥对,再用密钥加密 PIN 后落盘。更简单的中间方案是先把 PIN 做加盐哈希,只存哈希值,校验时比对哈希。哈希方案虽然没有加密那么强,但至少不会让明文直接暴露。

项目初期为了快速验证链路,我用的就是哈希方案,把 SHA-256 的结果存到应用沙箱文件里。安全强度对绝大多数应用锁场景足够,后续如果要上更高规格,再接入 HUKS。

6.2 暴力穷举防护

6 位数字 PIN 只有 100 万种组合,理论上可以穷举。所以应用锁必须做防暴力破解,最基本的策略是连续错误次数递增延迟。

实现起来很简单,在_verifyPin错误分支里维护一个计数器,连续错误 3 次后,每次校验前先等待若干秒,或者直接在一段时间内禁用输入。pin_code_fields 提供了enabled参数,可以方便地控制输入框是否可用:

enabled: !_isLockedOut,

锁定期间把_isLockedOut置为 true,输入框变为不可编辑状态,同时显示倒计时文案。这种策略虽然不能完全防住专业攻击,但能挡住绝大多数随手尝试的场景。

6.3 生物识别兜底

应用锁更完整的体验,是支持指纹或人脸识别兜底。输入 PIN 码作为备用方案。生物识别能力和 pin_code_fields 没有直接联系,需要通过 OpenHarmony 侧的生物识别接口封装一个平台通道,然后在 LockScreen 中优先调用。

我的建议是先完成 PIN 码主链路,生物识别作为二期迭代。因为生物识别涉及系统权限声明、设备能力检测、错误次数管理等一堆细节,如果一开始就并行做,容易把调试复杂度拉高。PIN 码链路跑通后,再在 LockScreen 顶部加一个“使用指纹解锁”按钮,通过平台通道调用系统能力,失败时仍然回落到 PIN 输入。

结尾想说的几句

这次适配让我比较深刻地体会到一件事:OpenHarmony 上接 Flutter 三方库,真正的卡点并不总在库本身,而常常在 SDK 分支、设备签名、输入法链路这些容易被忽略的工程环节。pin_code_fields 因为是纯 Dart 实现,适配成本比我预想的低很多,整个过程没有改动第三方库源码,只是在工程搭建和焦点处理上做了一些针对性适配。

如果你也准备在 OpenHarmony 上做应用锁,我的建议是把 pin_code_fields 的接入当作一个验证性步骤先跑通,再集中精力处理生命周期锁定和 PIN 安全存储。这两块才是应用锁体验和安全的真正核心。后续如果电视端场景跑通了,也欢迎来交流一下遥控器输入那部分的实现取舍。

返回列表