尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Flutter与OpenHarmony设置页开发:从Cubit状态管理到通道适配

Flutter与OpenHarmony设置页开发:从Cubit状态管理到通道适配 1. 设置功能在生活助手App里的定位与整体设计1.1 别把设置页当成简单的表单列表生活助手App一般做什么待办清单、健康打卡、记账、天气提醒棱角分明的小工具集合。设置页在这些功能背后其实是整个应用的“状态枢纽”。用户在这里改了主题、调了字体、关掉通知首页、列表页、详情页都得立刻响应。别小看这个页面它牵涉的不仅是UI展示还包括跨页面状态共享、本地持久化、系统权限联动以及OpenHarmony与Flutter之间的原生能力调用。动手写代码之前我先把设置项整理成一张表键名、类型、默认值、作用范围。比如主题模式我用theme_mode这个键存字符串枚举字体缩放用font_scale存浮点数通知总开关用notification_enabled存布尔值。这样定义清楚以后UI层、状态层、存储层各司其职后面每加一个新设置项只需要在表里加一行再在对应分组里加一个组件逻辑不会越写越乱。1.2 选型思考Flutter OpenHarmony 怎么搭顺手Flutter在这个项目里的价值很直观一套Dart代码跑安卓和鸿蒙UI保持一致开发阶段还能享受热重载。但OpenHarmony不是安卓它的SDK、构建工具、权限模型都有差异所以选择Flutter版本和适配分支是第一道槛。我的做法是直接用OpenHarmony团队维护的Flutter SDK分支不要用主线硬编版本对应关系在官方仓库里有明确说明。状态管理这块我选了flutter_bloc里的Cubit。相比完整BlocCubit更轻适合设置页这种“改一个值、存一下、通知页面更新”的简单场景。Cubit的代码结构也清爽一个Cubit类对应一个State类。如果你担心文件膨胀可以用Dart的part和part of拆分同一个类到多个文件不过现在Dart官方更推荐用多个独立文件加export组合我实际项目中基本都走后者。路由我用go_router设置页作为一个独立子页面放在/settings下。持久化方案是shared_preferences它在OpenHarmony上有适配底层走轻量数据库不需要额外配置。主题和字体这种全局偏好统一放在SettingsCubit里管理由它驱动根组件MaterialApp的themeMode和字体缩放比在每个页面手动读取存储再setState要可靠得多。2. 让Flutter工程在OpenHarmony设备上跑起来2.1 环境版本和工具链是个槛如果直接拿最新版Flutter主线编译OpenHarmony工程大概率会碰到一堆莫名其妙的错误比如“Current configured Flutter SDK is not known to be fully supported”这类提示。我一开始还不信邪试了一次编译期就崩了。后来老老实实按官方推荐的分支走组合是DevEco Studio 5.0、OpenHarmony SDK 5.0、适配过的Flutter 3.x分支。版本匹配关系建议直接抄官方README里的对照表别自由发挥。创建工程时先用flutter config确认ohos平台已经启用flutter config --enable-ohos-desktop flutter create --platforms ohos life_assistant_app如果你的DevEco Studio已经安装好了OpenHarmony SDK还需要把SDK路径配置到环境变量里比如DEVECO_SDK_HOME。这一步经常被漏掉漏掉之后flutter devices根本看不到鸿蒙设备报错还很隐晦。2.2 首次运行前需要检查的四个点确认目标设备或模拟器已经启动并且flutter devices能识别到。如果识别不到检查USB调试和驱动。配置hap签名。在build-profile.json5里设置好签名信息否则真机安装会直接失败模拟器上也会提示签名缺失。检查module.json5里的权限声明。要用网络、通知、存储得提前在这里声明否则运行时调用原生接口很容易被静默拒绝。第一次执行flutter run -d device-id会拉一批依赖耐心等。启动后密切关日志里有没有MissingPluginException一旦出现就说明某个插件还没适配鸿蒙。这里还要提一句渲染引擎。Flutter 3.7之后在部分平台默认走Impeller渲染OpenHarmony适配分支上Impeller还在不断完善。我在测试时碰到过偶发的花屏和文字渲染异常切换回Skia渲染后问题消失。遇到这类情况先别急着改业务代码试试在flutter run时加--enable-software-rendering模拟器场景很管用或者临时关掉Impeller看能不能复现原问题。3. 设置页面核心功能实现状态、UI和持久化3.1 页面布局分组列表 常用组件设置页的UI我建议用ListView.builder配合分组组件不要每个选项都写死一个ListTile。生活助手设置项不算多但分类清晰很重要。我按“外观—通知—存储—关于”四个分组来组织外观组里有主题模式、字体缩放通知组里有消息总开关、各类业务通知开关存储组里有缓存大小、清理缓存关于组里有版本号、开源许可、隐私政策入口。这里有一个交互小细节如果设置页内嵌了Tab比如“通用”和“高级”两个子页点击Tab切换时默认会带一个滑动动画有时会给人“拖泥带水”的感觉。TabBar默认自带切换动画可以通过设置physics: NeverScrollableScrollPhysics()、或者自定义TabController动画时长来削弱。热搜里那句“flutter tabbar点击取消动画效果”说的就是这类体验优化去掉了之后设置页的切换手感会干脆很多。3.2 主题模式跟随系统、浅色、深色三选一主题切换是设置页的“门面功能”实现路径非常直接设置项存一个枚举字符串Cubit改状态根组件监听到新状态后重设themeMode。先看数据模型和存储enum AppThemeOption { system, light, dark } class SettingsRepository { static const _themeKey theme_mode; Futurevoid saveThemeOption(AppThemeOption option) async { final prefs await SharedPreferences.getInstance(); await prefs.setString(_themeKey, option.name); } FutureAppThemeOption loadThemeOption() async { final prefs await SharedPreferences.getInstance(); final value prefs.getString(_themeKey); return AppThemeOption.values.firstWhere( (e) e.name value, orElse: () AppThemeOption.system, ); } }Cubit侧只需要两个方法一个加载初始值一个更新并持久化class SettingsCubit extends CubitSettingsState { SettingsCubit(this._repository) : super(SettingsState.initial()) { _load(); } final SettingsRepository _repository; Futurevoid _load() async { final theme await _repository.loadThemeOption(); emit(state.copyWith(themeOption: theme)); } Futurevoid setTheme(AppThemeOption option) async { await _repository.saveThemeOption(option); emit(state.copyWith(themeOption: option)); } }在根组件用BlocBuilder包住MaterialAppBlocBuilderSettingsCubit, SettingsState( builder: (context, state) { return MaterialApp( theme: AppThemes.light, darkTheme: AppThemes.dark, themeMode: state.themeMode, ); }, )这里我踩过两个坑。第一themeMode千万不要返回null否则Flutter不识别任何模式直接放弃跟随系统用户切了深色模式却毫无反应。第二SharedPreferences.getInstance()是异步的如果Cubit初始化时还没拿到存储数据就会用默认值创建状态然后启动后再异步去load表现为“启动瞬间闪一下默认主题再跳到用户偏好”。我的解法是在runApp之前先建立一个bootstrap流程把Repository初始化好再创建Cubit而不是在页面build里等异步数据。3.3 字体大小调节给生活助手加分的小功能生活助手里的待办列表、账本记录都是文本密集型界面字号调节对用户非常实用。Flutter实现字体缩放很简单旧API用MediaQueryData.textScaleFactor新版本推荐用textScalerMediaQuery( data: MediaQuery.of(context).copyWith( textScaler: TextScaler.linear(state.fontScale), ), child: child, )fontScale的取值范围我设成1.0到1.5步长0.1在设置页用一个Slider控制。这里要注意MediaQuery的包裹层级要够高。我习惯放在路由的builder层这样进入设置页调节后返回首页立刻生效不需要手动触发任何刷新。在OpenHarmony上我发现一个细节有些系统控件对话框比如日期选择器、时间选择器它们的字号不一定跟随Flutter的textScaler变化。如果生活助手里有这类原生控件只能依靠平台通道去同步系统字体或者接受“App内缩放和系统控件缩放不一致”这个现状然后在UI上做文案引导不要强行统一。考虑到设置页本身是App内功能我就没有深挖到系统级以免引入不必要的权限风险。3.4 通知开关本地状态 系统权限联动通知开关在设置页里看起来只是一个Switch但它背后连着系统权限。我的处理方式是“App内开关控制业务逻辑并同步请求系统通知权限”。Dart侧封装一个方法class NotificationService { static const _channel MethodChannel(life_assistant/settings); Futurebool enableNotifications(bool enable) async { try { return await _channel.invokeMethod(setNotificationEnabled, { enabled: enable, }); } on PlatformException catch (e) { // 权限被拒绝、接口未注册等 return false; } } }原生侧在收到调用后会调用OpenHarmony的通知服务接口比如在对应模块里请求requestEnableNotification最后返回授权结果。如果用户拒绝了系统权限App内的开关要立刻回滚到关闭状态并弹出引导提示不能出现“开关开着但系统通知不响”的割裂状态。这里还需要区分“总开关”和“分类开关”。生活助手里有事件提醒、健康打卡、账本记账三个业务通知开关它们存的是App内偏好每个开关都控制着对应业务要不要发本地通知。总开关则对接系统权限总闸。权限被拒时我建议直接把总开关置灰并显示“前往系统设置开启”避免用户和开关较劲。3.5 缓存清理统计与删除清理缓存是设置页里最直白的“工具型功能”实现不复杂但细节讲究。Flutter侧用path_provider获取临时目录然后递归计算目录大小FutureDirectory _getCacheDir() async { return await getTemporaryDirectory(); } Futureint _calculateDirectorySize(Directory dir) async { int total 0; await for (final entity in dir.list(recursive: true, followLinks: false)) { if (entity is File) { total await entity.length(); } } return total; }清理时只删除临时目录下的内容不要动文档目录。实际项目里生活助手会把图片缩略图放在临时目录聊天缓存则在数据库里所以要清理的目标是缓存文件目录而不是“应用所有数据”。还有一个我踩过的坑有些文件还在被当前页面持有直接删除会报FileSystemException解决办法是先关掉文件流再执行删除如果失败可以隔几百毫秒重试一次。统计大小显示要换算成可读格式。不要直接展示123456789字节写成“118MB”用户才看得明白。这里顺便提醒一下第一次清理后的getTemporaryDirectory()可能仍然返回目录但目录是空的再点一次清理会显示“0KB”这属于正常现象不用特别处理。3.6 多语言与关于页面的基本盘设置页往往还承担语言切换和关于信息的展示。多语言我用flutter_localizations加自定义的本地化委托语言选项作为设置项存进SharedPreferences。切换语言后需要触发MaterialApp重建我依然复用SettingsCubit里locale字段让BlocBuilder去刷新整棵组件树。这里要注意flutter_localizations支持的Locale列表要在MaterialApp的locale参数里设置不能只改GlobalMaterialLocalizations.delegate否则日期、时间组件不会跟随语言变化。关于页面就简单很多展示App名称、版本号、开源协议。版本号可以通过package_info_plus获取OpenHarmony端有适配。关于页面基本不涉及设置项状态所以直接走独立路由就好不需要拖进Cubit。4. 与OpenHarmony原生侧的双向通信设计4.1 MethodChannel在鸿蒙侧落地的正确姿势Flutter调用原生能力标准解法是MethodChannel。Dart侧代码和安卓几乎一致难点在鸿蒙侧的注册逻辑。鸿蒙的Ability模型和安卓的Activity不同我在MainAbility的onWindowStageCreate阶段注册通道处理器这样能保证Flutter引擎启动前就已经能处理Dart侧调用。一个简单的鸿蒙侧注册示意// 示意的结构具体入口以你的工程为准 const methodChannel new MethodChannel(life_assistant/settings, context); methodChannel.setMethodCallHandler((call) { if (call.method setNotificationEnabled) { const enabled call.arguments.get(enabled) as boolean; return notificationService.setEnabled(enabled); } return Promise.reject(new Error(method not found)); });实际开发中MethodChannel构造时的上下文要从Ability取不要自己new一个空的否则调用系统API时拿不到AbilityContext。Dart侧传入的参数类型也有限制String、num、bool、List、Map这些可序列化类型尽量不要传自定义对象跨端序列化容易出各种分辨率问题。4.2 EventChannel原生主动通知Flutter的正确思路有些信息原生侧知道得更早比如用户在系统设置里把App通知权限关了这时候Flutter侧还蒙在鼓里。轮询当然可以但污染代码、费电。更好的方案是EventChannel。Dart侧监听class NotificationStatusListener { static const _eventChannel EventChannel(life_assistant/settings_events); StreamMapdynamic, dynamic get statusStream { return _eventChannel.receiveBroadcastStream().map( (event) Mapdynamic, dynamic.from(event), ); } }在页面里监听override void initState() { super.initState(); subscription NotificationStatusListener().statusStream.listen((event) { if (event[type] notification_denied) { context.readSettingsCubit().setNotificationEnabled(false); } }); } override void dispose() { subscription?.cancel(); super.dispose(); }原生侧在权限状态变化时调用eventSink?.success(payload)即可。这里有个坑EventChannel的stream是在Dart侧订阅后才会真正建立通道原生侧如果不知道当前有没有订阅者就在初始化阶段往eventSink里塞数据那些数据会直接丢失。所以原生侧要维护一个hasListener的状态等Dart侧订阅成功后再把当前状态补发过去。4.3 从插件适配角度看“一套接口、两端实现”如果你不是只做设置页而是整个生活助手都要同时支持安卓和OpenHarmony建议把平台能力抽象成一层接口避免在页面里到处写MethodChannel。定义接口abstract class PlatformSettings { Futurebool setNotificationEnabled(bool enable); StreamNotificationStatus onNotificationStatusChanged(); FutureString getDeviceModel(); }然后分别写AndroidPlatformSettings和OhosPlatformSettings两个实现各自封装对应平台的通道细节。页面只依赖接口不感知平台差异。这种思路其实也是Flutter插件适配的一般流程先明确两端共同的能力边界再分别在android目录和ohos目录写实现最后在插件注册类里绑定。做完以后你会发现所谓“鸿蒙适配”并不是把安卓代码翻译一遍而是重新审视两端API差异、权限模型差异和生命周期差异。5. 实测中踩过的坑与排查思路5.1 MissingPluginException先检查注册时序设置页第一次打开就调用了PlatformSettings相关方法结果直接抛MissingPluginException。这个异常很常见尤其当你从安卓工程复制代码过来时。排查顺序我建议先看通道名是否一致再看鸿蒙侧有没有在正确的生命周期注册。你可能会犯一个低级错误在MainAbility的onCreate里注册通道但Flutter引擎那时候还没准备好真正可用的时机是onWindowStageCreate甚至更晚一点。把注册逻辑放到窗口加载完成后问题基本消失。5.2 SharedPreferences的key不能随便改生活助手在安卓已经有一批老用户鸿蒙版本来打算和安卓版“共享设置”结果我用了一套带下划线前缀的新key去存储老用户“升级”后设置全部恢复默认。这个问题不在于shared_preferences本身而在于业务层的key策略不统一。建议用常量类统一管理key并把key的职责写清楚。需要做数据迁移时写一个migration方法读取旧key的值写入新key然后清理旧key。class PrefsKeys { static const themeMode app_theme_mode; static const fontScale app_font_scale; static const notificationEnabled app_notification_enabled; }5.3 暗色模式切换时整页闪白用BlocBuilder包MaterialApp以后暗色切浅色时会闪一下白很刺眼。原因在于根组件重建的瞬间新的MaterialApp还没拿到新themeMode先用默认值建了一帧。我的解法是把持久化读取提前到bootstrap阶段把读到的themeOption作为Cubit初始状态这样Cubit一创建就带着正确的主题不会先渲染默认再切换。还有个小技巧在MaterialApp外层套一个Container(color: Theme.of(context).scaffoldBackgroundColor)把首帧背景色固定住闪烁概率会降低不少。5.4 缓存目录清理后App卡顿设置页“清理缓存”成功后我测试首页图片加载第一次明显变慢还偶发白屏。后来发现是清理逻辑太粗暴把临时目录里的图片缓存全部删掉了图片库首次加载需要重新生成缩略图。合理的清理策略应该是只清理超过N天未访问的临时文件或者清理“非当前会话产生的缓存”。为了让用户有获得感可以显示“清理xxMB”但实际删除范围可以做限制。硬要把所有缓存删光反而会让体验变差。5.5 OpenHarmony模拟器与真机的差异我在DevEco模拟器上调试通知权限弹窗、回调都正常换真机后调用直接没反应。真机上通知权限除了在module.json5声明还要求应用在系统设置里被明确授权对应通知类别比如锁屏通知、横幅通知。模拟器对这些细节往往宽松所以涉及到系统权限、蓝牙、设备通信还是要尽早用真机验证。生活助手里如果以后接入IoT功能比如控制智能灯泡模拟器上的测试结果可信度更低必须把真机测试当成硬性流程。下面整理一张我实际用到的排查速查表问题现象常见原因排查/解决MissingPluginException原生侧通道未注册或通道名不一致检查注册时机与通道名在onWindowStageCreate里注册设置项重启后丢失SharedPreferences key不一致或写入失败统一PrefsKeys常量检查是否在异步初始化前调用端侧权限无回调module.json5缺少权限声明补充权限声明真机确认系统设置授权主题切换闪白初始状态未读取持久化值在bootstrap阶段预载数据固定根容器背景色字体缩放对系统控件无效系统对话框不走Flutter MediaQuery接受差异或通过平台通道同步系统设置清理缓存后首帧卡顿删除了图片库实时缓存只清理临时文件限制删除范围和时间窗口5.6 设置项恢复默认值后需要重启生效有段时间用户反馈“在设置页把字体调到最大返回首页没变化”。排查后发现首页的MediaQuery是在路由builder层读取的而设置页通过Navigator.push返回时MaterialApp虽然重建了但首页路由的builder可能会被路由缓存住导致没有立即更新。解决方法是让首页的builder读取一个由根组件提供的ValueListenable或者干脆把字体缩放状态也放到SettingsCubit里首页用BlocBuilder包裹局部区域。这类问题不是OpenHarmony特有的但我是在鸿蒙适配时才第一次遇到说明跨端测试还是要把“返回上一页”这类操作当重点场景过一遍。6. 写在最后的实战体会6.1 先定义边界再动手写UI把设置功能完整实现一遍之后我更确信“设计先行”的价值。状态模型、存储key、平台通信接口这三样先定好页面只是把它们摆出来。临时在UI里塞逻辑当时爽后续每加一个设置项就要重构一次。设置页是App里最“平凡”的一环但在跨端场景下却最容易暴露出平台差异同样的SharedPreferenceskey策略不同会导致数据丢失同样的MethodChannel注册时机不同会导致调用失败同样的主题切换重建范围不同会出现闪白。这些问题都不是大坑但它们足够烦人而且往往要等到真机跑起来才发现。6.2 值得继续扩展的几个方向生活助手App的下一步我打算做多设备同步把用户偏好同步到云端这时候设置项的数据模型价值就体现出来了序列化方案可以直接复用。另外设置页可以考虑加一个“一键备份”功能把偏好导出到文件方便换机恢复。如果想把IoT能力也加进来设置页还可以预留“设备管理”入口通过EventChannel实时接收设备状态变化。希望这篇实战记录能帮你把Flutter for OpenHarmony这段路走得更顺尤其是那些看起来简单、实际上最容易翻车的平台细节。
返回列表