多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Flutter for OpenHarmony实战:瑞克和莫蒂角色列表

Flutter for OpenHarmony实战:瑞克和莫蒂角色列表 “前几天把公司现有的一套Flutter游戏图鉴App往OpenHarmony上迁移第一个功能模块就是《瑞克和莫蒂》角色列表。说句实话一开始我挺犹豫的——Flutter在OpenHarmony上的适配到底成不成熟跑起来会不会一堆问题但两周折腾下来整体结论是能跑而且比想象中稳但前提是你得知道坑在哪。”这篇文章不打算讲那些Demo级别的Hello World而是把我们实际实现“万能游戏库App”第一个业务模块——瑞克和莫蒂角色列表——的完整思路、代码结构和踩坑记录整理出来。整个模块看起来不大无非是一张列表、几个筛选条件、上拉加载更多但它把Flutter for OpenHarmony的几个关键问题全串起来了开发环境、网络请求、状态管理、组件通信、列表性能、真机调试。适合正在评估Flutter能不能上OpenHarmony的团队也适合那些已经被环境配置卡了两天还没跑起来的人。1. 为什么“万能游戏库App”选Flutter for OpenHarmony而不是ArkTS1.1 表面理由是跨端复用实际是生态占位“万能游戏库App”这个项目的定位是做一个聚合类的游戏资料库游戏信息、角色图鉴、攻略、社区内容都往里面塞。这类App有个特点——内容形态高度相似列表页、详情页、筛选页占了80%的场景。我们从一开始就是Flutter技术栈Android和iOS两端共用一套Dart代码UI层几乎不用重写。现在要进OpenHarmony生态摆在面前的有两条路一是用ArkTS重写一遍二是用Flutter for OpenHarmony适配层把现有代码搬过去。第一条路最“正统”毕竟OpenHarmony原生语言就是ArkTS。但对团队来说这意味着同一套业务逻辑要维护两套代码Flutter一套DartOpenHarmony一套ArkTS。两个UI框架两种状态管理方案两拨人或者一拨人记住两套东西长期成本非常高。第二条路也就是我们实际选的路——利用OpenHarmony SIG维护的Flutter适配分支把Dart代码直接编译到OpenHarmony上。先不说性能至少业务代码不用动列表页逻辑、网络层、模型层完全可以复用。做“万能游戏库”这类内容型应用跨端复用带来的收益是实打实的。1.2 Flutter for OpenHarmony目前的成熟度评估在真正动手之前我其实花了半天时间去摸底Flutter for OpenHarmony的成熟度。这里必须把话说清楚免得有人拿着这篇文章冲进去之后发现和预期不符。现状是OpenHarmony SIG在维护一个flutter_flutter的ohos分支同时有一个flutter_packages仓库专门放适配OpenHarmony的插件。基础的Dart运行时、Widget框架、渲染管线已经在OpenHarmony上跑通了但几个细节要心里有数Impeller渲染引擎在OpenHarmony上的适配还在推进中不同版本的Flutter引擎差异会比较明显建议直接用官方推荐的稳定分支不要追新。第三方插件的OpenHarmony支持度参差不齐。比如cached_network_image这类纯Dart包问题不大但涉及原生能力的插件就要单独确认有没有ohos实现。热重载Hot Reload在真机上能用但不是100%稳定有时候触发一次恢复不了得手动重启App。调试效率会打点折扣不过不至于没法用。坦白讲这个成熟度不能和Android/iOS比但对于一个内容浏览型App来说已经足够支撑实际业务了。我的建议是先拿最小功能模块比如一个列表页跑通全链路再决定是否大规模铺开。1.3 角色列表这个模块在整个项目里的位置回到“万能游戏库App”本身。这个App的第一期要做角色库模块而《瑞克和莫蒂》是最合适的切入点原因有三这个IP的角色数量适中公开APIrickandmortyapi.com的数据结构和字段很规整适合拿来练手和验证数据层设计。角色列表包含图片、状态标签、性别、物种、分页、筛选等几乎所有列表类业务的标准要素做好了可以直接复用到其他游戏图鉴里。等到第二期接入更多游戏IP时只需要替换数据源和模型层UI和状态管理几乎原样复用。所以这篇实战记录的真正价值不在于“瑞克和莫蒂”本身而在于它展示了一套在OpenHarmony上用Flutter实现内容列表的标准路径。2. 跑通Flutter OpenHarmony开发环境我踩过的配置坑2.1 拿对Flutter for OHOS的分支环境搭建是整个过程中最折磨人的环节而且绝大多数坑都不是代码问题是分支和版本对不上。第一步是获取正确的Flutter SDK。不要从flutter.dev官方渠道下载那个版本还不支持OpenHarmony平台。正确做法是到gitee上拉取OpenHarmony SIG维护的flutter_flutter仓库然后切到ohos分支。git clone https://gitee.com/openharmony-sig/flutter_flutter.git git checkout ohos这里就得提醒一句这个仓库的master分支和ohos分支的差异很大一定要在克隆之后确认自己切到了ohos分支。我在这一步吃过亏直接用默认分支配了半天结果flutter create的时候根本看不到ohos平台选项。接下来把SDK路径配到环境变量里。Windows上改PathmacOS/Linux上改.bashrc或.zshrcexport PATH$PATH:/你的路径/flutter_flutter/bin然后在终端里跑一下flutter --version能看到版本号不等于配好了还要确认命令里带不带ohos之类的标识。如果打印信息里只有Dart版本、没有ohos相关信息大概率是分支没切对。2.2 创建带ohos平台的工程结构SDK配好之后创建工程的命令和普通Flutter项目有点区别flutter create --platforms ohos rick_morty_library这里的--platforms ohos是关键。如果省略创建出来的工程是没有ohos目录的。执行完之后工程根目录下会出现一个ohos文件夹这就是OpenHarmony的原生工程骨架。它和Android的android目录、iOS的ios目录平级。ohos目录内部结构比较接近标准的OpenHarmony工程有entry模块、hvigor配置、module.json5等。这里有一点要特别留意即使flutter create已经帮你把工程骨架生成好了后续打开和构建还是要依赖DevEco Studio和配套的OpenHarmony SDK。具体的配套要求是DevEco Studio支持OpenHarmony API 9及以上版本建议直接用5.x的最新稳定版别用Beta。在DevEco Studio的SDK Manager里装好对应API版本的OpenHarmony SDK。首次打开项目时会同步hvigor和依赖网络不好的话同步时间可能很长耐心等别反复点刷新。另外pubspec.yaml里涉及到的包如果依赖了原生能力需要关注是否提供了OpenHarmony的适配。纯Dart包基本不会有问题比如provider、http这些直接用就行。2.3 设备连接与首跑验证设备连接这块Flutter for OpenHarmony和Android开发是两套工具链。Android用adbOpenHarmony用hdcHarmonyOS Device Connector。hdc在DevEco Studio的工具目录里自带建议把它也加到PATH方便命令行操作。连接真机后先验证设备状态hdc list targets设备能被hdc识别之后还需要确认Flutter能不能看到它。在项目目录下跑flutter devices如果设备列表里出现了一个名字类似RK3568_OHOS或者your_device_name (ohos)的条目说明Flutter工具链已经能管理这台设备了。这一步一切顺利的话直接flutter run能把这个跑起来环境配置这关就算过了。但是我得说首次运行大概率没这么顺利。常见的报错集中在hdc连接正常但flutter devices看不到设备——检查hdc和DevEco Studio内置hdc版本是否冲突统一用同一个。ohos的依赖同步失败——在DevEco Studio里手动Sync一次并检查代理配置。构建时报keystore错误——OpenHarmony工程默认需要签名先在DevEco Studio里配置好自动签名再回到命令行跑。这部分的教训就是不要试图绕过DevEco Studio做纯命令行构建。Flutter for OpenHarmony是“Flutter OpenHarmony原生工程”的混合体系原生侧的问题还是用原生工具解决两边结合起来才顺畅。3. 瑞克和莫蒂角色数据从公开API到Dart模型3.1 数据源与JSON结构拆解“瑞克和莫蒂角色列表”的底层数据用的是rickandmortyapi.com提供的公开接口。这个API是社区维护的数据覆盖了动画里出现的绝大部分角色每一条记录都包含了完整的角色信息。一个分页请求的格式是GET https://rickandmortyapi.com/api/character/?page1返回的JSON结构分两层{ info: { count: 826, pages: 42, next: https://rickandmortyapi.com/api/character/?page2, prev: null }, results: [ { id: 1, name: Rick Sanchez, status: Alive, species: Human, type: , gender: Male, origin: {name: Earth (C-137), url: ...}, location: {name: Citadel of Ricks, url: ...}, image: https://rickandmortyapi.com/api/character/avatar/1.jpeg, episode: [...], url: ..., created: 2017-11-04T18:48:46.250Z } ] }如果我们只是做一个角色列表真正需要渲染的字段并不多id、name、status、species、gender、image。但模型层建议还是把完整字段都解析出来。原因很简单——列表页用不到的不代表详情页用不到第二期做角色详情时再去补字段就要动解析逻辑了不如一次到位。3.2 模型层实现与字段映射Dart侧我建了一个Character类核心字段用非空类型兜底可空字段用String?处理class Character { final int id; final String name; final String status; final String species; final String type; final String gender; final String image; Character({ required this.id, required this.name, required this.status, required this.species, required this.type, required this.gender, required this.image, }); factory Character.fromJson(MapString, dynamic json) { return Character( id: json[id] as int, name: json[name] as String? ?? Unknown, status: json[status] as String? ?? unknown, species: json[species] as String? ?? unknown, type: json[type] as String? ?? , gender: json[gender] as String? ?? unknown, image: json[image] as String? ?? , ); } }有个细节值得单独说status和gender这两个字段真实API返回的值是Alive、Dead、unknown、Male、Female、Genderless等。它们是字符串但语义上是枚举。为了后续筛选方便我在模型层把它们定义成了Dart的enumenum CharacterStatus { alive, dead, unknown; static CharacterStatus fromString(String value) { switch (value.toLowerCase()) { case alive: return CharacterStatus.alive; case dead: return CharacterStatus.dead; default: return CharacterStatus.unknown; } } }为什么这么处理因为如果用裸字符串筛选逻辑里到处都是if (status Alive)这种魔法字符串一旦API返回的大小写变了整个筛选就静默失效。用枚举收敛之后解析和匹配逻辑都集中在一个地方一是好维护二是查询筛选时不容易写错。3.3 分页加载与错误处理列表模块另一个绕不开的设计点是分页。这个API是传统页码分页每页最多20条info里直接给出pages总数和next/prev链接。我一开始想省事用next字段逐页拉取后来发现一个问题当用户切换筛选条件后next会变但前端状态里如果不重置数据源容易叠加错乱。最终定的方案是自己维护页码page请求时主动传?page$pagestatus$filter不依赖接口返回的next。这是更可预测的做法——分页逻辑由客户端控制任何筛选条件下页码都是从1重新开始。HTTP请求用的是http包简单够用FutureCharacterPage fetchCharacters({ required int page, String? status, }) async { final queryParameters { page: $page, if (status ! null status.isNotEmpty) status: status, }; final uri Uri.https(rickandmortyapi.com, /api/character/, queryParameters); final response await http.get(uri).timeout(const Duration(seconds: 10)); if (response.statusCode ! 200) { throw Exception(Request failed with status: ${response.statusCode}); } final json jsonDecode(response.body) as MapString, dynamic; return CharacterPage.fromJson(json); }关于超时我特意加了.timeout(const Duration(seconds: 10))。OpenHarmony真机在部分场景下网络栈和Android不太一样DNS解析偶尔会慢不加超时的话用户可能会看到一个永久转圈的加载框。超时后应该抛异常并让上层状态管理去处理而不是在数据层静默吞掉。4. 角色列表页实现组件拆分与UI细节4.1 页面骨架与无限滚动列表页UI我采用了一个非常常规但稳定的结构顶部AppBar 筛选栏 角色列表。核心列表用的是ListView.builder配合ScrollController做上拉加载更多。class CharacterListPage extends StatelessWidget { override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text(瑞克和莫蒂角色库), ), body: Column( children: [ const CharacterFilterBar(), Expanded( child: ConsumerCharacterListViewModel( builder: (context, viewModel, child) { final characters viewModel.characters; if (viewModel.isLoading characters.isEmpty) { return const Center(child: CircularProgressIndicator()); } return ListView.builder( controller: viewModel.scrollController, itemCount: characters.length (viewModel.hasMore ? 1 : 0), itemBuilder: (context, index) { if (index characters.length) { return const ListLoadingIndicator(); } return CharacterCard(character: characters[index]); }, ); }, ), ), ], ), ); } }这里有个经验之谈ListView.builder在OpenHarmony上的滚动性能高度依赖itemExtent的设置。如果卡片高度是固定的强烈建议在builder里指定ListView.builder( itemExtent: 120, ... )Flutter会知道每个item的高度从而跳过布局计算滚动时GC和布局压力都会显著下降。我的角色卡片固定高度大约是120逻辑像素在OpenHarmony真机上滚动明显顺滑很多。4.2 角色卡片组件的设计角色卡片是列表里最核心的视觉单元。我的设计是左侧角色头像、右侧三行信息名字、物种、状态标签。CharacterCard的代码不复杂但细节集中在这几点class CharacterCard extends StatelessWidget { final Character character; const CharacterCard({super.key, required this.character}); override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 6, offset: const Offset(0, 2), ), ], ), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( character.image, width: 80, height: 80, fit: BoxFit.cover, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( character.name, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( ${character.species} · ${character.gender}, style: const TextStyle(fontSize: 13, color: Colors.grey), ), const SizedBox(height: 6), CharacterStatusTag(status: character.status), ], ), ), const Icon(Icons.chevron_right, color: Colors.grey), ], ), ); } }组件通信这部分有个细节值得强调CharacterCard本身不依赖任何状态管理它就是一个纯展示组件通过构造参数接收Character数据。这样设计的好处是以后无论是从列表页打开还是从搜索结果页、收藏页打开同一个卡片组件能直接复用。这也是组件通信中“父传子”最基础的形态——尽量多传数据、少依赖全局状态。4.3 筛选交互与加载状态筛选栏放在了AppBar下面结合这个API的实际字段我做了三个筛选维度状态全部 / Alive / Dead / unknown性别全部 / Male / Female / Genderless物种目前先用单选后面接入更多游戏IP时再扩展不过第一期为了控制复杂度先把状态筛选落地性别和物种暂时只是UI入口逻辑层预留好了。筛选交互上有一个反直觉的经验很多人会把筛选和列表放在同一个页面但筛选变化后触发列表数据重新加载时flutter把焦点和滚动位置全部重置了。为了避免这个问题筛选栏回调里要一并处理三个动作更新CharacterListViewModel中的筛选值。重置page 1。清空现有characters列表并重新请求。如果只做第一个动作列表尾部会出现旧数据和新数据混合的乱象。这块逻辑我直接放在了状态管理层的applyFilter方法里UI层只需要调一个方法。加载状态同样要拆开处理首次加载整页居中显示CircularProgressIndicator。上拉加载更多列表底部显示一个轻量级加载指示器。加载失败底部提示“加载失败点击重试”而不是弹一个全屏的Dialog。5. 状态管理与组件通信Provider在角色列表中的定位5.1 为什么不直接用setState做状态管理选型时“flutter provider 怎么用”是排在最前面的问题。选Provider而不是setState并不是因为它酷而是因为这页面的状态共享结构已经不适合setState了筛选栏要改筛选条件、列表页要读筛选结果、卡片要展示数据、上拉加载要改loading状态。如果全部靠Widget树一层层往下传递callback代码会迅速膨胀而且列表页和筛选栏的父子关系稍微一调整就要改一遍传参链路。Provider的核心价值不在于“状态放在哪里”而在于它让跨组件读取状态时不用关心组件层级。CharacterListViewModel就是这页的唯一状态中心class CharacterListViewModel extends ChangeNotifier { final CharacterRepository _repository; ListCharacter _characters []; bool _isLoading false; bool _hasMore true; int _page 1; CharacterStatus? _activeStatus; ListCharacter get characters List.unmodifiable(_characters); bool get isLoading _isLoading; bool get hasMore _hasMore; CharacterListViewModel(this._repository); Futurevoid loadFirstPage() async { _page 1; _characters []; _hasMore true; notifyListeners(); await _loadCurrentPage(); } Futurevoid loadNextPage() async { if (_isLoading || !_hasMore) return; _page 1; await _loadCurrentPage(); } Futurevoid applyFilter(CharacterStatus? status) async { if (_activeStatus status) return; _activeStatus status; await loadFirstPage(); } Futurevoid _loadCurrentPage() async { _isLoading true; notifyListeners(); try { final pageData await _repository.fetchCharacters( page: _page, status: _activeStatus?.name, ); _characters [..._characters, ...pageData.characters]; _hasMore pageData.hasMore; } catch (e) { // 保持原数据只更新错误状态 _hasMore false; } finally { _isLoading false; notifyListeners(); } } }5.2 ChangeNotifier Provider的完整链路在App根部挂Provider我放在了MultiProvider里方便以后扩展其他模块的状态void main() { final repository CharacterRepository(); runApp( MultiProvider( providers: [ ChangeNotifierProvider( create: (_) CharacterListViewModel(repository)..loadFirstPage(), ), ], child: const RickMortyApp(), ), ); }组件读取状态用的是Consumer。这个模块里我用到的Consumer只有两个CharacterListPage外层监听列表数据变化并刷新列表。CharacterFilterBar里的筛选按钮监听筛选状态变化来高亮当前选中的筛选项。这两处Consumer的粒度是最合理的。如果整个页面套一层Consumer任何字段变化都会触发整页重建性能损失不小。后来我改成只在上层用Consumer内部的卡片组件保持无状态明显流畅很多。这也是一个小技巧状态管理虽然叫“全局”但监听范围的粒度要控制好。5.3 组件通信的细节与避坑这模块里的组件通信不算复杂但有几个细节值得记录筛选栏和列表页没有直接通信而是通过同一个CharacterListViewModel联系。筛选栏点击后调用applyFilter通知监听者列表页的Consumer自动刷新。这样两者解耦以后筛选栏挪个位置代码不用动。上拉加载的滚动监听放在ViewModel里而不是UI层。ScrollController在CharacterListViewModel内部创建UI层通过viewModel.scrollController持用。当滚动到底部时ViewModel内部直接调用loadNextPage。页面销毁时ChangeNotifier由Provider框架自动dispose只要我们不手动close一般不会导致内存泄漏。但要注意那些通过Timer或异步回调触发的notifyListeners()页面销毁后再次通知就会报错。处理方式是在异步请求回来之前用if (!_disposed) notifyListeners()包一层安全判断。还有一件事在OpenHarmony上尤其要注意token过期、网络异常、设备切换等场景异步回调的时序和Android上不完全一致。OpenHarmony的线程调度在某些版本上对回调执行顺序更敏感异步结果乱序会导致列表数据错乱。我的应对办法是在_loadCurrentPage里记录当前请求的page返回后校验page是否和当前期望值一致不一致就丢弃。6. 真机实测中的坑与优化从卡顿到崩溃的排查6.1 图片加载与内存占用角色列表的第一版在OpenHarmony真机上跑最大的问题是图片。API返回的图片地址是固定的原图分辨率并不算高40张小图还好但当分页加载到第3页、累计60张图以上时内存和卡顿开始明显。排查过程是这样的先在系统设置里看App内存占用发现持续上涨且不回落。然后把列表静置十几秒再看发现甚至还在涨基本可以断定是图片缓存没有释放。处理方案有两个层次。第一层是缩略图这个API的图片URL支持加?image尺寸参数比如/avatar/1.jpeg?image96x96这样网络传输和内存解码都变小。第二层是换用图片加载库CachedNetworkImage( imageUrl: character.image ?image96x96, width: 80, height: 80, fit: BoxFit.cover, placeholder: (context, url) Container( color: Colors.grey.shade200, ), errorWidget: (context, url, error) Container( color: Colors.grey.shade300, child: const Icon(Icons.person, color: Colors.grey), ), )如果依赖生态支持不好也可以自己写一个简单的二级缓存内存文件但椵于时间我建议直接用成熟的图片库。Flutter生态中cached_network_image在OpenHarmony上属于纯Dart实现适配性很好直接用就行。必须提醒的是在OpenHarmony上不要对同一URL反复Image.network因为它的图片缓存机制和Android不完全一致很容易导致GC压力大。用Image.network撑场面可以生产环境就是找罪受。6.2 滚动性能优化列表滚动卡顿一开始我以为是OpenHarmony渲染引擎性能不行后来发现一半是优化没做到位。实测逐项排查我确认了下面这几个优化手段在OpenHarmony上是有效的按收益排序ListView.builderitemExtent。最有效固定高度直接跳过了布局计算。RepaintBoundary。给每个CharacterCard包一层减少重绘范围。卡片里的头像图、阴影、圆角都会触发绘制独立出图层后滚动时只有变化的区域会重绘。图片采用固定尺寸不做动态高度。一开始让图片按比例缩放滚动时图片的布局在每一帧都要重新计算性能差很多。避免在build方法里创建新对象。比如BoxDecoration、EdgeInsets这些尽量提出为常量减少垃圾回收。优化完之后在RK3568这类OpenHarmony开发板上列表滚动能稳定在50帧以上虽然达不到高端手机的那种丝滑但作为内容浏览已经可以接受了。6.3 OpenHarmony特有适配问题最后这部分是Flutter for OpenHarmony和普通Flutter最不一样的地方也是真题最容易翻车的地方。网络权限。OpenHarmony应用的网络权限声明不在AndroidManifest里而是在module.json5中。新建工程默认不一定会开放网络访问权限如果运行时发现HTTP请求直接失败先查这里{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }明文HTTP。API的地址是HTTPS没有遇到明文限制但如果以后要接本地开发环境的HTTP接口OpenHarmony会跟Android一样拦截明文请求。需要在network_security_config里配信任域或者直接用HTTPS。签名与安装。命令行flutter run到真机上如果提示安装失败大概率是签名没有配置。用DevEco Studio打开ohos目录完成自动签名后再回到命令行flutter run问题就解决了。日志排查。OpenHarmony上Dart侧的错误日志可以通过hdc log查看但更直接的方式是flutter run前台输出。如果崩溃后日志信息不够完整建议在main()里捕获未处理异常输出更详细的堆栈void main() { runZonedGuarded(() { runApp(const RickMortyApp()); }, (error, stack) { debugPrint(uncaught error: $error\n$stack); }); }生命周期差异。OpenHarmony的应用生命周期和Android略有差异后台切换时AppLifecycleState的触发时机不太一样。如果列表页在切后台再回来时出现白屏一般不是Flutter的问题而是引擎在OpenHarmony上的surface重建导致的需要在WidgetsBindingObserver里对AppLifecycleState.resumed做一次列表局部刷新兜底。还有一个值得留意的点在OpenHarmony上跑Flutter列表时一次构建的耗时明显比Android长。这也是这类跨端适配的常态工程结构多了一层原生封装。不要拿开发时的冷启动速度去评估最终用户体验——那是两码事。这些坑排完之后角色列表模块已经能稳定运行在OpenHarmony真机上了。后续这个模块要扩展的方向也很明确角色详情页、本地收藏、离线缓存、关键词搜索。从这次实战来看Flutter在OpenHarmony生态里确实当得起“业务复用优先”这个定位。如果你也在评估要不要把Flutter应用带到OpenHarmony上我的建议是先找个列表类的功能模块试水跑通数据、状态、组件通信这条链路剩下的事情都可以水到渠成。
返回列表