ARTICLE DETAIL

资讯详情

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

OpenHarmony上Flutter GridView实战与性能优化

OpenHarmony上Flutter GridView实战与性能优化 在 OpenHarmony 设备上跑 Flutter 并不算难难的是把它用到一个真实页面里数据要动起来、图片要加载、滚动要够稳、异常不能直接崩掉。这篇就围绕我看得最多也最常用的一个场景——GridView 网格视图——把 Flutter for OpenHarmony 从环境准备到实战落地的过程完整捋一遍顺带把我在这个过程中踩过的坑和排过的错一并交代清楚。如果你正准备在 OpenHarmony 上用 Flutter 做列表型应用或者已经跑完 Hello World 但觉得网格页面力不从心这篇应该能省你不少时间。1. 先把环境理顺OpenHarmony 上跑 Flutter 的正确姿势1.1 为什么不能用官方 Flutter SDK 直接跑很多朋友拿到 OpenHarmony 设备后的第一反应是去 flutter.dev 下载一个官方 SDK 然后flutter run结果大概率是 flie 能构建但设备列表里根本看不到 OpenHarmony 设备。原因不复杂官方 Flutter SDK 的引擎层并没有对接 OpenHarmony 的窗口、事件、图形栈你要用的是 OpenHarmony SIG 维护的flutter_flutter分支它在引擎层做了平台通道替换把 Flutter 的渲染和生命周期接到了 OpenHarmony 的 Native 环境上。我最初没太在意这个区别图省事直接装了稳定版 Flutter结果光是找设备就折腾了一个晚上。后来切到 OpenHarmony 对应的分支几分钟就连上了。这不是说官方 SDK 有问题而是平台适配这件事必须走专门的代码路径。1.2 环境配置的几步关键操作以我目前的使用经验比较顺的流程是这样安装 DevEco Studio配好 OpenHarmony SDK 和命令行工具确保能用hdc连上设备或模拟器。拉取 OpenHarmony 版本的 Flutter SDK比如flutter_flutter仓库的 OpenHarmony 3.2 及以上适配分支把它作为你的 Flutter 环境。配置环境变量让 flutter 命令指向这个 SDK并设置好OHOS_SDK_HOME指向 DevEco 安装目录下的 SDK 路径。在项目里启用 OHOS 平台支持重新打开终端先跑flutter doctor确认 OpenHarmony 相关项是绿色。用flutter create .或直接在 DevEco 里创建 Flutter 工程后连接设备确认flutter devices能看到你的目标机器。我见过最多的“新建项目后跑不起来”九成是环境变量没有刷新终端还是旧的 PATH导致 flutter 命令跑到官方 SDK 那里去了。另外还有一个小坑如果你电脑上装了多个 Flutter 版本一定要在flutter doctor里看清楚当前用的到底是哪一个flutter --version输出最直接。1.3 第一个 GridView 验证页面环境通没通最快的方式不是跑默认计数器工程而是直接写一个 GridView 页面跑起来看。因为我最终业务要用的就是网格与其在默认工程里满屏找按钮不如直接一步到位import package:flutter/material.dart; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); override Widget build(BuildContext context) { return MaterialApp( title: OHOS Grid Demo, home: Scaffold( appBar: AppBar(title: const Text(OpenHarmony GridView)), body: GridView.count( crossAxisCount: 2, crossAxisSpacing: 8, mainAxisSpacing: 8, padding: const EdgeInsets.all(12), children: List.generate(12, (index) { return Container( color: Colors.blueAccent.withOpacity(0.4), alignment: Alignment.center, child: Text(Item $index), ); }), ), ), ); } }这段代码能点亮说明 OpenHarmony 的设备连接、Flutter 适配分支、工程构建链路全通了。我在设备上第一次看到这个两列网格的时候心里那块石头才算落地——后面要做的数据加载、下拉刷新、卡片交互全都是在这个基础上往上搭的。2. GridView 的四种构造方式怎么选才不亏2.1 count 和 extent固定数量与自适应宽度的取舍GridView 最常用的两个构造是GridView.count和GridView.extent。它们的区别一句话就能说清前者固定交叉轴的数量后者固定每一项交叉轴的宽度再根据屏幕宽度反推出列数。比如上面验证页里用的GridView.count(crossAxisCount: 2)在手机上无论横竖屏都按两列走。而GridView.extent(maxCrossAxisExtent: 200)的意思是每个子项最大宽度不超过 200 逻辑像素屏幕或容器变宽时列数自动变多变窄时自动变少。这里要提醒一个常见的坑用count时如果你只设置了crossAxisCount而不处理childAspectRatio默认的宽高比是 1:1。但当单元格里放的是图片加两行文字时内容很容易被裁剪或溢出。所以更稳妥的做法是用SliverGridDelegateWithFixedCrossAxisCount在里面同时声明childAspectRatio和间距然后手动调一个适合你卡片内容的比例比如 0.75 甚至 0.7宁可让它略矮一点也不要让文字溢出。2.2 builder动态数据列表的懒加载核心不管是 count 还是 extent只要子项数量是固定的、且数据量不大用起来都很舒服。但真实业务里网格数据往往是动态的今天 10 条明天 800 条。这种场景要用GridView.builder它的核心优势是懒加载只在视图需要时构建可见区域附近的子项屏幕外的一律不建。GridView.builder( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 0.75, crossAxisSpacing: 10, mainAxisSpacing: 10, ), itemCount: items.length, itemBuilder: (context, index) ProductCard(item: items[index]), )我第一次把GridView.count直接改成builder时顺手把itemCount写成了items.length 1本意是想在末尾多放一个加载提示结果数组越界崩了。这个习惯一直影响到后来在itemBuilder里一定要对索引多做一层判断或者单独用 hasMore 之类的状态控制尾巴视图不要让索引跨界因为 Flutter 在 Release 模式下的越界不像调试模式这样给出一眼能看懂的红色堆栈。2.3 custom把控制权握在手里的场景GridView.custom是四种构造里最底层的一个它可以让你自定义SliverGridDelegate和子项构建器。如果只是普通列表用不上它但当你需要“第一行跨两列展示大图后面每行都是双列卡片”这种特殊布局时custom是唯一能干净实现的方式。或者你想把整个网格嵌进CustomScrollView里和顶部的轮播、中间的横幅、底部的推荐位做整体滚动联动那也不是直接用 GridView 套一个 SliverPadding 就能优雅解决的而是需要把网格背后的SliverGrid暴露出来。custom的意义在于Grid 这个 Sliver 是可以无限组合的但如果你用 count 或 builder网格内部的结构默认是独立滚动的放进 CustomScrollView 里就很不自然。我之前做过一个资讯首页头部有 Banner中间是频道标签下面是网格流。为了所有区块一起滚动我把网格用GridView.customSliverGridDelegateWithMaxCrossAxisExtent包成一个 Sliver塞进CustomScrollView。这样既保留了网格的懒加载能力又拿到了滚动布局的完全控制权。四种构造的适用场景我归纳如下构造方式适用场景注意点GridView.count固定列数的小型网格注意 childAspectRatio 溢出GridView.extent宽度自适应、跨屏适配要求高列数是动态算出来的GridView.builder数据量不确定、无限流加载关注 itemCount 与索引边界GridView.custom嵌入 CustomScrollView、复杂 Sliver 布局需自行指定 delegate 与构建器日常开发里 builder 用得最多count 适合快速验证custom 是复杂页面的底牌。选型的原则很简单数据少、结构定怎么顺手怎么来数据一多一定要回到 builder 上去。3. 一个能吃的商品网格数据流与三态视图的搭法3.1 数据模型与 Provider 状态容器网格视图本身只是“展示层”真正让页面活起来的是数据。在 OpenHarmony 上做 Flutter 状态管理我最常用的组合是ProviderChangeNotifier这也是社区里问得最多的问题Provider 到底怎么用以商品网格为例我先定义模型class Product { final int id; final String title; final String coverUrl; final double price; const Product({ required this.id, required this.title, required this.coverUrl, required this.price, }); }接着是状态容器。注意它要继承ChangeNotifier所有会影响 UI 的字段变更都要在操作完成后调用notifyListeners()class ProductGridModel extends ChangeNotifier { final ListProduct _items []; bool _isLoading false; bool _hasMore true; bool _hasError false; int _page 1; ListProduct get items List.unmodifiable(_items); bool get isLoading _isLoading; bool get hasMore _hasMore; bool get hasError _hasError; Futurevoid refresh() async { _page 1; _items.clear(); _hasMore true; _hasError false; notifyListeners(); await _fetchPage(); } Futurevoid loadMore() async { if (_isLoading || !_hasMore) return; await _fetchPage(); } Futurevoid _fetchPage() async { _isLoading true; _hasError false; notifyListeners(); try { final data await _api.fetchProducts(_page); _items.addAll(data.items); _hasMore data.hasMore; _page; } catch (_) { _hasError true; } finally { _isLoading false; notifyListeners(); } } }这里有一个细节refresh里先_items.clear()再notifyListeners()是为了让 UI 先清空再进入加载态避免旧数据残留看起来像“没刷新成功”。我在第一次实现时只是在请求成功后替换整个列表结果下拉刷新以后网格瞬间一闪又变回新数据中间出现了明显的跳动感。在工程入口处用ChangeNotifierProvider包一层ChangeNotifierProvider( create: (_) ProductGridModel(), child: const HomePage(), )页面里通过context.watchProductGridModel()拿到状态数据一变网格自动重建这就是 Provider 的基本使用逻辑。3.2 RefreshIndicator 下拉刷新与滚动监听加载更多网格列表基本逃不开两种交互下拉刷新和上拉加载。标准下拉刷新在 Flutter 里是RefreshIndicator包住Scrollable而 GridView 本身就继承 Scrollable 的特性RefreshIndicator( onRefresh: () context.readProductGridModel().refresh(), child: GridView.builder( controller: _scrollController, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 0.75, crossAxisSpacing: 10, mainAxisSpacing: 10, ), itemCount: model.items.length (model.hasMore ? 1 : 0), itemBuilder: (context, index) { if (index model.items.length) { return const Center(child: CircularProgressIndicator()); } return ProductCard(product: model.items[index]); }, ), )请记住这个 itemCount 的写法多出来的那一个是给底部加载提示占位的。判断条件是hasMore而不是_isLoading否则第一条数据还没加载完就会先渲染一个“加载中”格子看起来很蠢。上拉加载的触发方式有两种一种是用ScrollController监听滚动位置另一种是用NotificationListener监听ScrollNotification。我用得比较多的是前者_scrollController.addListener(() { if (_scrollController.position.pixels _scrollController.position.maxScrollExtent - 200) { _loadMore(); } });减 200 的意思是提前 200 逻辑像素触发加载这样用户滑到底部时数据已经在后台拉取了。这个阈值不能太小否则在网格这种一屏内容量很大的组件里用户会明显卡一下才看到新内容。3.3 加载中、空数据、错误三个态一个都不能少真实项目里网络状态决定了一个页面能不能“吃”。我最早做这个商品网格时只写了正常态结果联调当天就被测试反馈一页白屏配一个无限转圈毫无优雅可言。后来补上了三态视图把页面拆成这样if (model.isLoading model.items.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (model.hasError model.items.isEmpty) { return ErrorView(onRetry: () model.refresh()); } if (model.items.isEmpty) { return const EmptyView(); }这里面的顺序是有讲究的先判断加载中再判断错误最后判断空。很多人会先把空数据写在最前面导致刷新时列表本来还没数据瞬间先跳出“空空如也”观感极差。加载中优先能让用户看到一个稳定的初始状态有错误又没数据时展示重试按钮有数据时哪怕有请求到了尾页正常网格也照常展示底部再放一个“没有更多了”的小字提示。还有一个细节加载更多失败时我一般不清空已有列表也不直接弹 Toast而是在底部留一行“加载失败点击重试”点一下只重新请求下一页。这个在不打断用户浏览的前提下把错误暴露出来的方式在网格这种大量内容的页面里比弹窗友好得多。4. 卡片交互里的细节点击、图片缓存与轻动效4.1 InkWell 的点击态在 OpenHarmony 设备上的表现网格里的每一个卡片通常都是可点击的。常规做法是在卡片最外层包InkWell它可以触发水波纹效果。OpenHarmony 设备上 Flutter 的 InkWell 水波纹渲染主要是走自绘的 Material 组件和 Android 上是同一套实现基础所以手感基本一致。但有一个实际体验需要注意OpenHarmony 部分设备的触控采样率和默认点击响应阈值偏高如果卡片点击后没有任何反馈比如纯GestureDetector用户会觉得“没点中”于是会连点好几次反而触发重复跳转。我的做法是用InkWell而不是GestureDetector保证点击有可见反馈设置borderRadius与卡片圆角一致让水波纹不出界在跳转逻辑上做好防重入例如用Navigator.push之前判断当前路由是否存在。卡片的圆角我通常定为 8 到 12 逻辑像素水波纹的 borderRadius 要和它保持一致InkWell( onTap: () _openDetail(context, product.id), borderRadius: BorderRadius.circular(10), child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), ), padding: const EdgeInsets.all(8), child: ... ), )4.2 图片加载与缓存网格性能的关键战场网格里的图片数量是列表页的好几倍一张屏幕可能同时存在 8 张以上的网络图片。如果你直接在itemBuilder里写Image.network你会发现两个问题一是快速滚动时图片闪白、加载极慢二是没有缓存策略滑出去再滑回来图片又重新加载一遍。图片这块我建议直接用cached_network_image这个包它是基于 flutter 的图片缓存体系做的一层封装能省掉大量重复的网络请求和内存占用。在 OpenHarmony 上使用时它依赖的底层 HTTP 栈走的是 Flutter 引擎自己的网络实现我在实际测试中没有遇到兼容性问题。用法很简单CachedNetworkImage( imageUrl: product.coverUrl, fit: BoxFit.cover, placeholder: (context, url) Container( color: Colors.grey.withOpacity(0.2), child: const Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ), ), errorWidget: (context, url, error) Container( color: Colors.grey.withOpacity(0.2), child: const Icon(Icons.broken_image, color: Colors.grey), ), )占位和错误替换很重要。网格滚动时如果图片加载失败却显示一个空白的破框视觉上特别突兀。我习惯把错误图固定成一个灰色底加一个破图 Icon这样至少看起来还是“有这个卡片”的只是图片没显示出来。至于图片尺寸我一直用 2 倍图也就是网络图片的实际像素宽度大约是 UI 上显示尺寸的两倍。OpenHarmony 设备的分辨率普遍不低如果你直接加载原图内存占用会成倍上涨尤其网格场景下更容易触发引擎内存告警。我甚至见过缩略图源尺寸是 2000 像素宽、UI 只显示 150 像素宽的案例这种图一旦在网格里铺开卡顿几乎是必然的。4.3 用 AnimatedContainer 做轻量点赞反馈网格卡片里如果有点赞、收藏这类操作一定不要直接改数据然后原地刷新整个网格——那样代价太高而且会闪。更好的方式是把反馈做在卡片内部的有状态组件里。比如我实现的一个关注按钮class FavoriteButton extends StatefulWidget { final bool initialFavorited; final VoidCallback onChanged; const FavoriteButton({ super.key, required this.initialFavorited, required this.onChanged, }); override StateFavoriteButton createState() _FavoriteButtonState(); } class _FavoriteButtonState extends StateFavoriteButton { late bool _favorited; override void initState() { super.initState(); _favorited widget.initialFavorited; } override Widget build(BuildContext context) { return GestureDetector( onTap: () { setState(() _favorited !_favorited); widget.onChanged(); }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( shape: BoxShape.circle, color: _favorited ? Colors.red.withOpacity(0.2) : Colors.grey.withOpacity(0.1), ), padding: const EdgeInsets.all(6), child: Icon( _favorited ? Icons.favorite : Icons.favorite_border, color: _favorited ? Colors.red : Colors.grey, size: 20, ), ), ); } }这样的好处是单个按钮的动画和状态只在自身组件内部重建不会因为setState触发整个 GridView 的 itemBuilder 重跑。如果这里改成直接操作ProductGridModel里的某个字段然后notifyListeners()数据流是干净了但性能代价就上来了。实际开发里我常常把这两种方式结合起来按钮本地做视觉反馈业务状态异步上报数据刷新后再通过本地数据对比保证最终一致。5. 排障实录卡顿、报错与 OpenHarmony 特有坑5.1 网格滚动卡顿的定位思路OpenHarmony 设备上的 Flutter 网格卡顿问题通常不在 Flutter 框架本身而在业务层数据构造和图片解码。我先讲一个亲身经历一个商品网格页列表只有 40 条数据但滚动时掉帧明显。我起初怀疑是 GridView 的懒加载问题后来把cacheExtent强制设小依旧没有改善。最后定位到根因每个商品卡片里的图片都是 2000 像素的宽图而卡片显示区域只有 150 像素宽。图片在解码端占了巨大的内存带宽每次滚动到新区域解码任务都要占用大量 CPU。解决手段有这三板斧服务端或 CDN 按需返回缩略图这永远是最优解本地图片加载时用ResizeImage控制解码后的实际尺寸比如ResizeImage.resizeIfNeeded(300, 300)网格数据多时适当调大cacheExtent比如默认值之上加一屏半让卡片提前构建而不是滑到边缘才临时加载造成白块。我后来还发现一个问题OpenHarmony 上 Flutter 默认的渲染后端是 Skia而热词里提到的 Impeller 渲染引擎虽然在部分平台表现很好但在 OpenHarmony 适配分支上我目前不建议主动开启。Impeller 优先支持的平台是 iOS 和部分 Android 设备OpenHarmony 分支对它的适配还在推进中。如果项目里遇到过一些小毛刺先检查是否开启了 Impeller 对应的渲染选项关掉回到 Skia 往往就稳定了。5.2 那个经典的 Dart VM 初始化报错如果你在日志里见过这么一行E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception不要慌这个报错的关键词是“Unhandled Exception”本质上就是某个 Dart 异常没有被捕获。在网格页面里最常见的是两类一类是RangeError另一个就是异步请求时 state 已经 dispose 掉结果回调里还在notifyListeners()导致异常。我当时在 OpenHarmony 真机上遇到这个问题是在网络请求返回时页面已经被切走ProductGridModel的_fetchPage回调还执行了notifyListeners()。这不算 Flutter 或 OpenHarmony 的 bug而是生命周期处理不对。修复方式是if (!mounted) return;但模型不是 State没有mounted。所以在模型里我会加一个标识状态bool _disposed false; override void dispose() { _disposed true; super.dispose(); } void _safeNotify() { if (!_disposed) notifyListeners(); }然后把所有notifyListeners()替换成_safeNotify()。这样即使页面已经销毁模型也不会在错误时机去通知 UI 重建。排查这个报错时还有个技巧报错信息下面会带 Dart 调用堆栈别只看第一行。堆栈最后会指向ProductGridModel._fetchPage这一层一眼就能定位到问题函数。5.3 OpenHarmony 特有坑版本匹配与多渠道打包OpenHarmony 上的 Flutter 开发最大的坑不是写代码而是版本匹配。同行们经常在社区里反馈的一个问题是Flutter SDK 分支升级了原来能跑的项目突然起不来设备上也是各种找不到符号。这基本是 SDK 与 OpenHarmony SDK 版本没有对齐导致的。举个我实际遇到的例子OpenHarmony SDK 升级后旧 Flutter 适配分支里的原生编译路径没有跟上表现为构建阶段报一堆undefined symbol。这时候不要急着改代码先去查官方适配分支的版本说明确认它对应的是哪个 OpenHarmony SDK 版本然后对齐版本重新构建。另外如果你要把 Flutter 工程打包进 OpenHarmony 应用商店或做整机集成可能遇到 XTS 认证相关的问题。XTS 认证会检查应用权限声明、API 版本使用情况以及原生库的合规性。Flutter 引擎会生成若干.so文件安装到设备上有些 XTS 项会对动态库或包体积做校验你需要在打包配置里把这些 so 的 ABI 目录和文件名整理干净。我第一次打包时就被提示存在未授权动态库访问风险其实就是 Flutter 的可执行库被安全检测单独拎出来了补上对应权限说明后就没问题了。还有一个让我印象深的坑flutter build hap或类似产物在集成到原生工程时路径里面不能有空格也不能放在中文目录下会有很诡异的报错。这个跟 AAR 时代的集成问题一模一样解释不清但真实存在我建议所有 OpenHarmony Flutter 的工程都统一放在纯英文无空格路径下。5.4 网格末尾的加载失败处理我前面提到了底部的“加载失败点击重试”这里补充它的实现细节。我的方案是在状态模型里增加一个loadMoreFailed标志加载更多失败时置为 true网格 itemBuilder 判断如果索引等于 items.length 且loadMoreFailed为 true就渲染一个可点击的提示条if (index model.items.length model.loadMoreFailed) { return GestureDetector( onTap: () context.readProductGridModel().retryLoadMore(), child: const Center( child: Text(加载失败点击重试), ), ); }retryLoadMore里要把loadMoreFailed先置为 false然后重新走_fetchPage。这个设计的好处是用户不需要通过下拉刷新恢复可以直接在当前位置重试也不会因为重试导致列表回到顶部避免用户翻到一半被打断的烦躁感。网格不比线性 List它一屏内容多、信息密度大交互反馈如果不够细腻用户很容易觉得页面“死板”。在这些边角场景的处理上多花一点心思体感会完全不一样。最后再分享一个这几年下来形成的小习惯在 OpenHarmony 上做 Flutter 网格页面我会把网格本身的代码尽量写成纯 Dart、纯 Flutter 依赖所有平台相关的能力都夹在dart:io或image_picker这类插件后面。这样以后如果团队要切换平台GridView 页面几乎不需要动。网格布局本身是跨端能力差异最小的部分最大的平台差异永远在网络、文件、权限和原生 UI 这些外层。把这一层隔离开OpenHarmony 版本维护成本会低很多这也算是走过这一圈以后最有价值的体会了。
返回列表