ARTICLE DETAIL

资讯详情

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

Flutter+OpenHarmony基于shared_preferences的本地登录注册持久化方案

Flutter+OpenHarmony基于shared_preferences的本地登录注册持久化方案 近几年在 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 Listint.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 登录流程与校验逻辑登录比注册多几个步骤读取存储信息、校验密码、更新登录态。Futurebool 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 是否存在以及是否在有效期内。Futurebool 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()。表面看没问题但遇到极端情况——比如存储数据还没加载完首帧就已经开始渲染登录页了——会出现登录态闪跳的问题。我的做法是引入一个小型的启动门闩Futurevoid 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都返回Futurebool说明操作是排队执行的。但我仍然建议在需要连续写入多字段时使用一个统一的封装方法Futurevoid 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.watchAuthProvider()获取状态。class AuthProvider extends ChangeNotifier { bool _isLoggedIn false; String? _username; bool get isLoggedIn _isLoggedIn; String? get username _username; Futurevoid 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(); } } Futurevoid 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 这个版本踩过的一些值得记录的 bugv1.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; Futurebool saveUser(LocalUser user) async { final prefs await SharedPreferences.getInstance(); final success await prefs.setString(_prefsKey, jsonEncode(user.toJson())); return success; } FutureLocalUser? getUser() async { final prefs await SharedPreferences.getInstance(); final raw prefs.getString(_prefsKey); if (raw null) return null; return LocalUser.fromJson(jsonDecode(raw) as MapString, dynamic); } Futurebool 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持久化实现本地登录注册听起来好像是个很小的点真正做完才发现里面涉及插件适配、异步时序、加密策略、状态管理一套完整链路。希望这篇分享能帮你少踩几个坑。如果你在自己的设备上复现时遇到问题可以重点检查插件注册和初始化时序这两块绝大多数问题都出在那里。
返回列表