近几年在 OpenHarmony 上做应用开发,绕不开一个现实问题:生态还在成长,但业务需求已经摆在那里了。我们团队在做一个叫“GitCode 口袋工具”的小应用时,面临的选择很直接——要么用 ArkTS 从头写一套,要么用 Flutter 跨端复用现有代码。最终我们选了 Flutter + OpenHarmony 的组合,并且用shared_preferences完成了本地登录注册的持久化方案。这篇文章就围绕 v1.0.4 这个版本,把整个实现思路、踩过的坑、以及为什么这么设计聊清楚。
1. 为什么在 OpenHarmony 上选择 Flutter,而不是 ArkTS 原生
1.1 跨平台代码复用是现实需求
先交代一下背景。GitCode 口袋工具本身不是只跑 OpenHarmony 一个平台,Android、iOS、甚至桌面端都有对应版本。如果每个平台都维护一套原生实现,光是登录注册这种基础功能,就要写四遍逻辑、做四遍测试、修四遍 bug。在团队资源有限的前提下,这显然不划算。
Flutter 的优势在于,UI 层和业务逻辑层绝大部分都可以跨平台复用。我们在 Android 上已经积累了一套比较成熟的 Dart 代码,迁移到 OpenHarmony 时,只需要处理平台相关的插件适配和少量差异代码。实测下来,登录注册模块的 Dart 代码复用率超过 95%,剩下那 5% 的差异主要来自插件初始化和生命周期处理。
1.2 OpenHarmony 的 Flutter 支持现状
很多人对 OpenHarmony 跑 Flutter 的印象还停留在“能用但很难用”,但实际上从 OpenHarmony 3.2 开始,官方的 Flutter 适配已经比较可用了。OpenHarmony 分支基于 Flutter 官方版本做了一套适配,挂在 Gitee 的 OpenHarmony-SIG 仓库下。你不需要自己维护一套 Flutter engine,直接用适配过的 flutter_flutter 仓库和 flutter-engine 就行。
要注意的是,这个适配分支的版本跟进节奏和 Flutter 官方不完全同步。比如我们用的 Flutter 3.x 版本,OpenHarmony 适配分支会滞后一点。所以选版本的时候不能无脑追新,要看 OpenHarmony 适配分支支持到哪个版本。我的建议是直接看 OpenHarmony-SIG 的 flutter_flutter 仓库发布标签,选最新的稳定标签,而不是挑 Flutter 官方的某个新版本。
另外一个很容易忽略的问题是,OpenHarmony 的 Flutter 不使用标准 Gradle 打包流程,而是通过 DevEco Studio 配合 harmonyos 插件进行集成。如果你的工程之前是面向 Android 的 Flutter 工程,做 OpenHarmony 适配时需要额外添加ohos目录和对应的工程配置,这一步很多人会漏掉,后面我们会详细说。
2. shared_preferences 插件在 OpenHarmony 侧的适配:不只是换个包名
2.1 插件桥接原理:Dart 层 API 到平台通道
shared_preferences是 Flutter 社区最常用的轻量级键值存储方案。它之所以受欢迎,是因为接口足够简单——getString、setString、remove、clear这些方法一看就懂,底层自动帮你处理了不同平台的数据持久化。
在 OpenHarmony 上,shared_preferences并不能直接使用官方版本,因为官方版本只实现了 Android、iOS、macOS、Windows、Linux 等平台通道。OpenHarmony 需要由 OpenHarmony-SIG 社区维护的shared_preferences分叉版本,或者你自己写一个平台通道实现。
原理上并不复杂:Dart 层的SharedPreferences对象通过MethodChannel发消息给原生侧,原生侧收到调用后,把数据写到本地存储。OpenHarmony 侧的存储实现用的是Preferences接口(以前叫OhosPreferences),它同样提供键值对读写能力,底层持久化逻辑由系统管理。
2.2 工程配置:pubspec 与 ohos 插件注册
如果你用的是社区适配版本,配置相对简单。在pubspec.yaml中引入:
dependencies: shared_preferences: ^2.2.0 shared_preferences_ohos: ^1.0.0然后执行flutter pub get,再进入ohos目录用 DevEco Studio 打开工程,等待 Gradle 同步完成。但如果你用的是官方shared_preferences,编译时会报MissingPluginException,因为 OpenHarmony 平台没有对应的插件实现。
这里有一个我在 v1.0.4 开发中踩过的坑:当pubspec.yaml同时存在官方shared_preferences和社区shared_preferences_ohos时,OpenHarmony 侧的注册逻辑可能不会自动生效。你需要检查ohos/module.json5或者工程里的插件注册脚本,确保shared_preferences_ohos的 native 代码被打进 HAP 包。最直接的验证方式,是在真机上跑一遍setString再杀死进程重启读取,如果读不到数据,基本可以确定插件没有注册成功。
2.3 本地持久化方案横向对比
做完技术选型时,我也纠结过要不要直接用数据库或者文件存储,毕竟登录注册以后很可能扩展出其他数据需求。这里把我的对比结论列出来,供同样在做 OpenHarmony 应用的团队参考:
| 方案 | 实现难度 | 适用场景 | OpenHarmony 生态成熟度 |
|---|---|---|---|
| shared_preferences | 极低 | 少量键值数据、用户偏好、登录态 | 社区适配可用,但需注意版本 |
| 文件存储(JSON) | 低 | 结构化数据量不大,需要完全控制格式 | 高,纯 Dart 可以搞定 |
| 关系型数据库(如 sqlite) | 较高 | 数据结构复杂,查询需求多 | 中等,需要额外适配 |
| 分布式数据(如分布式数据库) | 高 | 多设备协同场景 | 依赖设备组网,不适合单机 |
对于登录注册这个场景,数据量很小——无非是用户名、密码哈希、token、过期时间这几十个字段。用共享参数存储完全够用,而且读写速度极快,不需要引入额外的数据库初始化成本。等到后续需要存多条用户记录,或者要按条件查询时,再考虑迁移到数据库也不迟。这也符合“先解决当下问题,不为未来过度设计”的原则。
3. 本地登录注册的完整实现:数据模型与流程设计
3.1 用户数据模型与序列化设计
本地登录注册的核心是用户信息如何建模。不建议直接把用户对象 JSON.stringify 后塞进一个字段里,因为后续改字段结构时会导致数据兼容性问题。更好的做法是分字段存储,每个字段单独一个 key。
我设计的用户数据模型如下:
class LocalUser { final String username; final String passwordHash; final String salt; final String token; final int createdAt; final int lastLoginAt; LocalUser({ required this.username, required this.passwordHash, required this.salt, required this.token, required this.createdAt, required this.lastLoginAt, }); }对应的存储 key 规则是:
class PrefKeys { static const String username = 'local_user_username'; static const String passwordHash = 'local_user_password_hash'; static const String salt = 'local_user_salt'; static const String token = 'local_user_token'; static const String createdAt = 'local_user_created_at'; static const String lastLoginAt = 'local_user_last_login_at'; }这样做的优势是,后续你只需要读取某个具体字段,不用把整个对象拉出来再解析。而且shared_preferences本身就是逐个 key 维护的,这种存储方式与底层机制完全匹配,效率也高。
3.2 注册流程的实现细节
注册逻辑表面上是收集用户名和密码,然后写入本地,但真正落地时需要考虑几个问题:
唯一性检查——注册前先查询local_user_username是否已有值。这里要注意的是,如果SharedPreferences中某个 key 不存在,读取返回的是null,而不是空字符串。判断唯一性时务必用== null或isEmpty双重判断,避免初始状态下出现误判。
密码存储方案——明文存储是不可接受的,即使这是本地存储。我用的是加盐哈希方式,先生成一段随机 salt,然后对password + salt做 SHA-256 哈希。Dart 侧可以通过crypto包来实现:
import 'package:crypto/crypto.dart'; import 'dart:convert'; String generateSalt() { final random = Random.secure(); final bytes = List<int>.generate(16, (_) => random.nextInt(256)); return base64Encode(bytes); } String hashPassword(String password, String salt) { final bytes = utf8.encode(password + salt); return sha256.convert(bytes).toString(); }注册完成后,我还会额外写入一个createdAt时间戳,这不仅是数据完整性的一部分,后续用户信息展示和统计也能用得上。
重复注册拦截——如果检测到用户名已存在,直接弹出 SnackBar 提示,并停留在注册页清空密码输入框。这一点是体验细节,但很多应用确实忽略了,用户以为注册成功,结果登录时才发现“账号已存在”,观感很差。
3.3 登录流程与校验逻辑
登录比注册多几个步骤:读取存储信息、校验密码、更新登录态。
Future<bool> login(String username, String password) async { final prefs = await SharedPreferences.getInstance(); final storedUsername = prefs.getString(PrefKeys.username); if (storedUsername == null || storedUsername.isEmpty) { return false; } final storedHash = prefs.getString(PrefKeys.passwordHash); final salt = prefs.getString(PrefKeys.salt); if (storedHash == null || salt == null) { return false; } final inputHash = hashPassword(password, salt); if (inputHash == storedHash) { await prefs.setString(PrefKeys.token, generateToken()); await prefs.setInt(PrefKeys.lastLoginAt, DateTime.now().millisecondsSinceEpoch); return true; } return false; }登录成功后生成 token 这一步很重要,它让登录态可以被验证,也为后续接入服务端预留了空间。v1.0.4 里 token 用的是随机 UUID,后续如果要接远程服务,可以换成服务端下发的 token。
3.4 会话恢复:启动时检查登录态
应用启动时,用户是否还需要重新登录,这是本地登录注册设计的关键。我的处理是检查 token 是否存在,以及是否在有效期内。
Future<bool> isLoggedIn() async { final prefs = await SharedPreferences.getInstance(); final token = prefs.getString(PrefKeys.token); if (token == null || token.isEmpty) { return false; } final lastLogin = prefs.getInt(PrefKeys.lastLoginAt) ?? 0; final now = DateTime.now().millisecondsSinceEpoch; return (now - lastLogin) < _sessionTimeout; }v1.0.4 设置的会话超时时间是 7 天。这个数值不是拍脑袋定的——对于工具类应用,用户打开频率不会特别高,7 天免登录能保证体验,同时又能防止一次登录永久有效带来的安全风险。
4. 持久化读写中的坑:初始化、异步与并发
4.1 初始化时机:为什么放 main() 里不够
SharedPreferences.getInstance()是异步方法,很多初学者直接把这个调用放在main()里,然后继续执行runApp()。表面看没问题,但遇到极端情况——比如存储数据还没加载完,首帧就已经开始渲染登录页了——会出现登录态闪跳的问题。
我的做法是引入一个小型的启动门闩:
Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final prefs = await SharedPreferences.getInstance(); final isLogged = await checkLoginState(prefs); runApp(GitCodeApp(initialLoggedIn: isLogged)); }把登录态检查结果作为参数传给根组件,首帧渲染时就能直接决定展示登录页还是主页面,而不是先展示一个空白页面再跳转。这个看起来很小的细节,对用户体验影响非常大。我个人实测过,不做启动门闩时,应用冷启动会出现大约 300ms 到 1s 的“白屏后跳转”状况,体感上非常廉价。
4.2 异步并发:多次 set 是否会互相覆盖
shared_preferences的写入是异步的,如果你同时发起多个setString调用,底层是否会发生覆盖?从 API 的返回值看,每个setString都返回Future<bool>,说明操作是排队执行的。但我仍然建议在需要连续写入多字段时,使用一个统一的封装方法:
Future<void> saveUserLocal({ required String username, required String passwordHash, required String salt, }) async { final prefs = await SharedPreferences.getInstance(); await Future.wait([ prefs.setString(PrefKeys.username, username), prefs.setString(PrefKeys.passwordHash, passwordHash), prefs.setString(PrefKeys.salt, salt), prefs.setInt(PrefKeys.createdAt, DateTime.now().millisecondsSinceEpoch), ]); }注意Future.wait只能保证这些任务全部完成,不能保证原子性。如果中途某个写失败,你可能会得到一份部分更新的数据。所以更稳妥的做法是,在写之前先备份旧值,写失败则回滚。不过对于 v1.0.4 这类工具应用,我觉得备份逻辑不需要太复杂,写失败直接让用户重试即可。
4.3 从 Android 迁移到 OpenHarmony:存储路径差异问题
这是本版开发最有收获的一个坑,值得单独拿出来说。
在 Android 上,shared_preferences底层把数据写到应用私有目录的 XML 文件中;而 OpenHarmony 的shared_preferences适配实现,同样是写到应用私有目录下的 preferences 文件,文件路径通常在/data/app/el2/100/base/{bundleName}/haps/{hapName}/files/下面。
问题在于,如果你从 Android 平台迁移数据到 OpenHarmony,不能直接把 Android 的 XML 文件拷贝进去。因为 OpenHarmony 对应用沙箱路径和文件格式有自己的一套校验机制,强行拷贝轻则读不到数据,重则被系统判定为非法文件写入,导致应用启动异常。我们曾经在测试机上试过,结果 HAP 包直接崩溃,日志指向Preferences读取失败,排查了半天才发现是文件格式不兼容。
所以,如果要做跨平台数据迁移,正确的方式是导出成通用的 JSON 文件,在新的平台上重新导入写入。这条道我给大家探过了,是真的行不通。
4.4 真机验证:重启之后登录态是否真的保留
本地登录注册和纯内存登录最大的区别,就在于“杀掉进程重启还记不记得”。我在 v1.0.4 的测试阶段单独列了一份验证清单:
- 注册新账号 → 杀掉进程 → 重启应用 → 应显示已登录状态
- 登录旧账号 → 手动清空应用数据 → 重启应用 → 应显示未登录状态
- 连续登录登出 3 次 → 每次重启都验证登录态是否正确
- 修改系统时间到会话超时之后 → 重启应用 → 应要求重新登录
其中最后一项很关键。因为本地登录态依赖时间戳判断是否过期,如果用户手动修改系统时间,可能出现会话永久有效或立即失效的极端情况。在 v1.0.4 中,我暂时没有做时间篡改检测,这是给下一版留的优化项。
5. 登录态保持与安全加固:shared_preferences 之外的几点补充
5.1 Token 与会话有效期设计
shared_preferences只负责存储,它不负责策略。登录态的“有效期”完全是应用层自己定义的上层逻辑。我在 token 生成和校验上做了两件事:
第一,token 使用Uuid生成随机字符串(v1.0.4 暂时不用 JWT,因为纯本地场景不需要解码 payload)。第二,每次冷启动时检查lastLoginAt和当前时间的差值。整个逻辑不复杂,但作为这套本地登录体系的骨架,它就是会话管理的全部。
如果后续要扩展成服务端登录,只需要把 token 生成逻辑替换成服务端返回的 access_token,把校验逻辑替换成请求远程接口,其他部分基本不变。这算是给未来留了一个合理的接口设计。
5.2 密码哈希与敏感信息存储方案
前面提到的加盐哈希已经是本地存储密码的底线方案。但我额外建议两点:
盐值务必随机且一用户一盐。如果全应用共用同一个盐,两个用户密码相同的话哈希结果也会相同,等于白做哈希。加盐后再做一次 HMAC 也可以,但 v1.0.4 还没做到这一步。原因是本地应用被 Root 攻破的场景比较极端,主要防的是“拿到文件后直接读取明文字段”这种初级风险。后续版本我计划引入更严格的安全策略,比如用 OpenHarmony 的密钥库接口做加密存储,把密码哈希再包裹一层。
5.3 结合 Provider 做全局登录态管理
如果你想把这套本地登录注册用得更顺手,建议和provider结合使用。v1.0.4 的架构里,我是把登录态放在一个AuthProvider里,所有需要判断登录状态的页面通过context.watch<AuthProvider>()获取状态。
class AuthProvider extends ChangeNotifier { bool _isLoggedIn = false; String? _username; bool get isLoggedIn => _isLoggedIn; String? get username => _username; Future<void> restoreSession() async { final prefs = await SharedPreferences.getInstance(); final token = prefs.getString(PrefKeys.token); if (token != null && token.isNotEmpty) { _isLoggedIn = true; _username = prefs.getString(PrefKeys.username); notifyListeners(); } } Future<void> logout() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(PrefKeys.token); await prefs.remove(PrefKeys.lastLoginAt); _isLoggedIn = false; _username = null; notifyListeners(); } }这样做的直接好处是,当用户执行登出操作时,所有监听登录状态的页面会自动刷新 UI,不需要手动写一堆状态同步代码。这也是 Flutter 社区比较推荐的组件通信与状态管理方式。如果你对provider不熟,网上有不少教程,但核心概念就三个:ChangeNotifier、Provider、Consumer。登录注册这种场景用起来非常顺手。
5.4 登出时该清哪些数据
最后说登出。很多开发者在登出时只清 token,用户名和密码哈希仍然留着。这也不能说错——保留用户名可以在下次登录页做个“上次登录账号”的提醒,方便用户快速输入。但如果你的应用涉及隐私数据,我建议登出时把 username 也一并清掉,避免多用户共用一台设备时暴露前一个人的账号信息。
v1.0.4 的登出口径是:保留 salt 和 passwordHash,清 token、lastLoginAt、username。这样即使用户误登出,下次还能用保存的账号一键快速登录。不过如果是他人借用设备,这个设计反而有泄露风险。这里没有一个标准答案,完全取决于你服务的用户场景。
在 v1.0.4 里我倾向于轻量快速,所以保留了账号信息。后续如果做企业场景,可以提供一个“退出并清除所有本地数据”的选项,交给用户自己选择。
6. v1.0.4 的迭代心得与下一版规划方向
6.1 这个版本踩过的一些值得记录的 bug
v1.0.4 相对上一版主要在三个问题上做了修复:
第一,OpenHarmony 侧偶发的“插件未注册”问题。因为shared_preferences的 ohos 实现需要注册到 HAP 包里,我遇到过一次编译成功、运行期却找不到 MethodChannel 的情况。排查方式是在日志里搜索MissingPluginException,定位到是打包时漏掉了shared_preferences_ohos的 so 文件。解决方法是检查ohos/module.json5中模块依赖是否包含shared_preferences_ohos,手动加上并重新构建。
第二,异步初始化竞态。早期版本里我在main()中同步创建SharedPreferences实例,结果某些老设备上首帧渲染比数据加载完成还快,导致页面呈现错误的登录态。后来改了启动门闩方案,症状消失。
第三,token 过期检查不准。最初我用的是lastLoginAt + timeout < now的判断,但忽略了lastLoginAt可能为null的情况,导致新安装应用第一次启动时,isLoggedIn返回true。这个问题真的很隐蔽——它的现象是“全新安装后直接进入了主页面”,而不是“进入登录页”。定位时我一度以为是路由初始值传错了,后来在日志里打印默认值才发现是null与0的语义区别。修复方式是在读取lastLoginAt时显式给默认值0。
6.2 代码组织上的一个建议:单独建一个 prefs 工具类
如果你要把这套功能复用到自己的项目里,我强烈建议不要把SharedPreferences的调用散落在各个页面。我在 v1.0.4 中把读写逻辑收敛在一个LocalStorageService类里,页面层完全不知道数据是怎么存的:
class LocalStorageService { static const _prefsKey = 'local_auth'; Future<bool> saveUser(LocalUser user) async { final prefs = await SharedPreferences.getInstance(); final success = await prefs.setString(_prefsKey, jsonEncode(user.toJson())); return success; } Future<LocalUser?> getUser() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_prefsKey); if (raw == null) return null; return LocalUser.fromJson(jsonDecode(raw) as Map<String, dynamic>); } Future<bool> clearUser() async { final prefs = await SharedPreferences.getInstance(); return prefs.remove(_prefsKey); } }这种封装的好处是,未来如果要把存储层从shared_preferences换成文件或数据库,只需要改这一个类,页面层零改动。这对后续迭代非常重要——你不想因为换存储方案把整个登录注册模块翻个底朝天。
6.3 下一版的方向和预留思路
v1.0.4 的本地登录注册已经稳定跑了一段时间,目前来看有几个方向值得继续完善:
一是增加“本地数据导出/导入”功能,便于用户备份登录数据或迁移到新设备。二是把 token 从随机 UUID 升级为服务端可验证的格式(如果对接远程服务)。三是对敏感字段做 OpenHarmony 密钥库级别的加密,而不是只靠哈希。四是用sqflite或drift做本地数据库,为未来记录多条用户数据、历史操作记录做准备。
这些方向并不一定要全做,但代码底座已经留好了接口。LocalStorageService的封装让存储层切换成本很低,AuthProvider的全局状态管理让 UI 层扩展也相对容易。如果你正在做类似的事情,建议也在早期就把这两层结构定好,后面扩展会轻松很多。
回到标题本身——Flutter + OpenHarmony 下基于shared_preferences持久化实现本地登录注册,听起来好像是个很小的点,真正做完才发现里面涉及插件适配、异步时序、加密策略、状态管理一套完整链路。希望这篇分享能帮你少踩几个坑。如果你在自己的设备上复现时遇到问题,可以重点检查插件注册和初始化时序这两块,绝大多数问题都出在那里。