ARTICLE DETAIL

资讯详情

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

Flutter跨平台拼豆图纸查看器开发实战:从鸿蒙适配到性能优化

Flutter跨平台拼豆图纸查看器开发实战:从鸿蒙适配到性能优化 最近手工社群里好几个人问我你们平时那个拼豆图纸手机上看眼睛都要瞎了放大以后拖动还卡你们有没有什么软件能看图纸、标进度说实话市面上的拼豆App要么是外文的、要么很久没更新能打的几乎没有。索性自己动手做一个。这个项目最终选了 Flutter 框架来做跨平台目标平台直接锁定了 Android、Windows 和鸿蒙设备。为什么要带鸿蒙因为现在不少人的主力设备、平板甚至折叠屏都是鸿蒙系统一套代码能同时覆盖三个端对个人开发者来说收益非常明显。这篇文章就把虚拟拼豆图纸查看应用从零到一的完整过程记录下来重点放在 Flutter 在鸿蒙开发侧的落地细节、图纸数据建模、网格渲染和真机适配几个方向。如果你是正在评估 Flutter 做鸿蒙应用的开发者或者想给手工作品配一个跨平台查看器这篇应该能帮你少踩不少坑。1. 为什么把拼豆图纸应用挂在 Flutter 鸿蒙这条线上先说结论Flutter 做这类工具型应用性能完全够用开发效率却比原生高出一大截。但真正推动我做这个选型的是拼豆图纸这个使用场景本身的特殊性。1.1 拼豆玩家真正需要的是什么拼豆Perler Beads / Hama Beads / Artkal的本质就是在一块固定规格的网格底板上一格一格插上彩色塑料豆最后用熨斗加热熔化成一个完整图案。所以拼豆图纸的本质也非常简单一个二维网格每个格子填一个颜色编号加上色号图例。但就是这个看似简单的需求市面上的工具做得都很别扭纸质图纸对色号很累翻来翻去容易看串行。电子版通常是图片格式放大到能看清色号的时候画面全是马赛克拖动手感还很差。拼到一半想标记“哪几个格子拼过了”绝大多数看图工具做不到。在手机上竖屏看一份横向 44 × 44 的图纸格子小到怀疑人生换到平板上看又得重新适配。所以我在 MVP 阶段就给这个应用圈定了四个核心功能网格和色号清晰渲染、缩放平移跟手、点击格子做“已完成”标记、跨端打开同一份图纸文件。把这些做好就已经能覆盖九成玩家的日常需求了。1.2 Flutter 在鸿蒙生态里的真实位置说到 Flutter 鸿蒙开发很多人第一反应是“Flutter 不是不支持鸿蒙吗”这个印象其实是滞后的。目前 OpenHarmony 社区和鸿蒙生态里已经有一条比较成熟的 Flutter 适配路线可以通过 OpenHarmony SIG 维护的 flutter_flutter 仓库ohos 分支或者 DevEco Studio 的 Flutter 插件来跑起来。鸿蒙应用开发目前有两条主流路线一条是 ArkTS 原生开发适合深度调用系统能力和需要极致性能的场景另一条是 Flutter / RN 这类跨平台框架适合 UI 密集、业务逻辑复杂、需要快速上多端的应用。拼豆图纸查看器明显属于后者——它没有特别复杂的系统能力需求但 UI 渲染量大一个 44 × 44 图纸就是 1936 个格子而且对跨平台一致性要求高。Flutter 在鸿蒙上的渲染是自己用 Skia / Impeller 画的不依赖原生控件。这意味着同一份图纸在 Android、Windows、鸿蒙三个平台上看到的效果是像素级一致的。对拼豆这种“颜色绝对不能偏”的应用来说这个特点非常关键。1.3 一套代码覆盖三个端收益在哪我实际开发下来业务层代码数据模型、解析逻辑、状态管理、图纸渲染基本是百分之百复用的只有平台相关的小部分需要单独处理后面第 5 章会细说。维护成本从“三个端三套代码”降到了“一套代码加少量平台适配”Bug 修复也只需要改一处。另外 Flutter 在桌面端的表现也被低估了。拼豆玩家经常需要在电脑前一边看图纸一边拼Windows 桌面端用 Flutter 做出来跟移动端的差距非常小。大屏看整图、小屏看细节这个体验是纯移动端方案给不了的。所以如果你的目标不只是“做一个鸿蒙App”而是“做一个能在手机和平板、电脑上都顺手的工具”Flutter 这套选型非常合适。2. 鸿蒙侧 Flutter 环境配置里最容易踩的坑环境配置这块我前前后后折腾了两天网上教程很多但版本口径特别乱。这里把几个关键点理清楚能帮你省下大量试错时间。2.1 先分清你用的是哪个 Flutter 版本这是最容易被坑的地方普通 Flutter SDK 是不带鸿蒙平台的。“flutter create”创建的工程默认只有 android / ios / web / windows / linux / macos 这些目录没有 ohos。你需要的是一份适配了 OpenHarmony 的 Flutter SDK。实操路径是这样从 OpenHarmony SIG 的仓库拉取 ohos 分支的 Flutter SDK然后配置到本地。git clone -b ohos-3.7 https://gitee.com/openharmony-sig/flutter_flutter.git拉下来之后目录结构会多出一个ohos文件夹这才是鸿蒙工程的壳。后续执行flutter create .或flutter run时如果配置正确你就能看到 ohos 平台的构建选项。另外一个更省事的方案是直接用 DevEco Studio 里的 Flutter 插件创建项目的时候会自动把适配好的 Flutter SDK 拉起来。但我不太推荐新手一上来就用 IDE 一把梭因为你不知道它背后干的到底是什么等出问题的时候排查起来很被动。先用命令行把链路跑通再回到 IDE 里工作心态会稳很多。2.2 配置文件里的几个关键字段跑通鸿蒙工程之后最重要的文件是ohos目录下的配置。和 Android 的AndroidManifest.xml类似鸿蒙侧也有一套自己的应用描述文件需要声明模块名、设备类型、能力入口和权限。我项目里的ohos/entry/src/main/module.json5核心字段长这样{ module: { name: entry, type: entry, deviceTypes: [phone, tablet, 2in1], abilities: [ { name: EntryAbility, srcEntry: ./ets/entryability/EntryAbility.ets, description: $string:EntryAbility_desc, icon: $media:icon, label: $string:EntryAbility_label, startWindowIcon: $media:startIcon, startWindowBackground: $color:start_window_background, exported: true, skills: [ { entities: [entity.system.home], actions: [action.system.home] } ] } ] } }同时要在工程根目录的local.properties里确认 SDK 路径指到了正确的 OpenHarmony SDK否则后面走 Flutter 构建时会找不到 HOS SDK 的编译工具。提示这类配置文件在不同版本的 DevEco Studio 里字段会有些出入不要照抄网上老资料的完整文件以你自己本机 IDE 生成出来的为准。我上面这份只是让你知道哪些字段跟跨平台打包相关。关于权限我当时只声明了最基本的网络权限后面要拉远程图纸列表和读取本地文件的权限。鸿蒙的权限模型和 Android 类似运行时权限需要动态申请但纯本地查看图纸的场景不用碰它保持最小权限集反而是好事。2.3 用 flutter doctor 验证环境时看什么配置完 SDK 之后执行flutter doctor -v。关键不是看它有没有打绿色勾而是看它列出的可用平台里有没有 OpenHarmony / HarmonyOS 这一项。如果没有说明 Flutter SDK 路径没有生效或者flutter命令用的还是普通版 SDK。我建议按这个顺序排查执行flutter --version确认当前 SDK 的分支和版本符不符合 ohos 适配要求。执行which flutter确认命令指向的是不是你刚配置的那份 SDK。如果你是通过flutter config或者环境变量指定的多版本 SDK检查一下当前 shell 环境是否切换正确。最后再跑flutter doctor -v看OpenHarmony平台是否被识别。另外鸿蒙开发还需要hdc鸿蒙设备调试工具和ohpm鸿蒙包管理器。这两个在 DevEco Studio 里一般会自带但命令行环境下需要手动加到 PATH 里。我的做法是把DevEco Studio安装目录下sdk/default/openharmony/toolchains和tools/ohpm/bin都加进环境变量这样flutter run才能在真机上装包和调试。3. 图纸数据模型一张 44 × 44 拼豆图是怎么存进 JSON 的拼豆图纸查看器的核心资产是图纸数据。一开始我直接拿图片当数据源测试结果越做越别扭最后还是改成了结构化数据。这一章讲讲数据模型的设计思路和实现细节。3.1 拼豆图纸的本质是二维颜色矩阵拼豆图纸不复杂底板规格比如 29 × 29、44 × 44、58 × 58每个格子对应的颜色编号再加上一张色号表。拿 44 × 44 举例就是 1936 个格子每个格子存一个整数编号这个编号指向调色板里的一个具体颜色。你可以把图纸理解成一个 Excel 表格行列坐标就是格子位置单元格内容填的是色号。我在做数据模型时就是按这个思路设计的——一份图纸 表头信息 调色板 二维矩阵。这里有一个容易忽略的细节不同拼豆品牌的色号体系不一样。Perler 的色号、Hama 的色号、Artkal 的色号各不相同即使是同一个颜色在不同品牌里的编号和颜色值也不同。所以数据模型里必须给调色板留出扩展空间不能只存一个整数矩阵就把颜色写死。3.2 JSON 结构与解析代码我设计的 JSON 结构大致如下{ name: 小狐狸, author: 手工博主, boardType: square-44, palette: [ { id: 1, color: #FF6B35, name: 橙色 }, { id: 2, color: #FFFFFF, name: 白色 }, { id: 3, color: #000000, name: 黑色 } ], rows: [ [1, 1, 2, 2, 1, 1], [1, 3, 2, 2, 3, 1] ], progress: { finished: [3, 7, 12] } }几个字段的设计考量boardType用字符串而不是两个数字字段是为了兼容未来非正方形底板圆形板、六边形板。palette单独定义调色板图纸矩阵里只存 id可以大幅减小文件体积。rows二维数组每个元素是调色板 id注意从左上角到右下角的行列顺序要跟拼豆底板一致。progress已拼接的格子索引列表用一维索引而不是二维坐标方便 Set 去重和快速判断。Dart 侧的数据模型我用json_serializable生成但手工写 fromJson 也很简单。核心模型长这样class BeadPattern { final String name; final String boardType; final ListPaletteColor palette; final ListListint rows; int get width rows.first.length; int get height rows.length; Color colorForCell(int row, int col) { final id rows[row][col]; return palette.firstWhere((p) p.id id).color; } } class PaletteColor { final int id; final Color color; final String name; }解析时一定要做数据校验。我当时被一份手工编辑出错的 JSON 坑过一次一个格子的色号 id 写到了调色板范围之外UI 直接崩了。后面我加了防御性解析如果某个 id 在调色板里找不到就用一个醒目的“错误色”标记出来并在页面上提示用户图纸文件损坏而不是直接白屏。3.3 为什么不用图片直接当图纸源你可能会想拼豆玩家手上大多是图片图纸直接看图不就行了这个想法我一开始也有但实际做下来发现图片当数据源有三个硬伤缩放清晰度图纸图片放大到能看清格子边界的时候像素被插值放大颜色边缘全是锯齿根本没法准确对色号。无法提取语义图片里一个格子是“橙色”对程序来说只是 RGB 值。你没法让它知道这个格子是否已经被拼完也没法做搜索和统计。文件体积大一份高清图纸图片可能几 MB而同样内容的结构化 JSON 只有几十 KB网络加载和本地存储都轻松得多。所以我的方案是JSON 作为应用内部的标准数据格式图片作为“外部输入”将来接一个图片识别功能把图纸图片转成 JSON第 6 章会细说。这样数据链路是干净的不会出现“一处图片一处云”的混乱局面。4. 网格渲染、缩放和进度标记的实现思路说完了数据进入正题怎么把几千个格子流畅地画出来并且让用户能缩放、拖动、点击标记。4.1 CustomPainter 画豆子与画网格有 Flutter 基础的人第一反应可能是用 GridView 或者 Wrap Container 去渲染格子。这对 10 × 10 的小图确实可以但到了 44 × 44就是 1936 个 Widget每次 StatefulWidget 的 setState 都要重建整棵 Widget 树帧率会非常难看。我最终用的是 CustomPainter直接在一张 Canvas 上把豆子画出来。核心思路背景画底板颜色通常是浅灰色或黑色突出豆子颜色。按行列循环画每个豆子豆子是带一点圆角的矩形避免颜色块之间完全拼死。网格线单独画一层使用半透明白色或黑色起到分隔作用。class BeadBoardPainter extends CustomPainter { final BeadPattern pattern; final double cellSize; final Setint finishedCells; override void paint(Canvas canvas, Size size) { final bgPaint Paint()..color const Color(0xFF2A2A2A); canvas.drawRect(Offset.zero size, bgPaint); for (int row 0; row pattern.height; row) { for (int col 0; col pattern.width; col) { final rect Rect.fromLTWH( col * cellSize, row * cellSize, cellSize, cellSize, ); final color pattern.colorForCell(row, col); final paint Paint()..color color; canvas.drawRRect( RRect.fromRectAndRadius(rect.deflate(1), const Radius.circular(2)), paint, ); } } // 画网格线 final gridPaint Paint() ..color Colors.white.withOpacity(0.08) ..strokeWidth 1; for (int i 0; i pattern.width; i) { canvas.drawLine( Offset(i * cellSize, 0), Offset(i * cellSize, pattern.height * cellSize), gridPaint, ); } } override bool shouldRepaint(covariant BeadBoardPainter oldDelegate) { return oldDelegate.cellSize ! cellSize || oldDelegate.finishedCells ! finishedCells; } }这里有个细节网格线不是每时每刻都画。当cellSize小于 10 像素时网格线会糊成一团反而影响观感。我是在cellSize超过一定阈值才画网格线小于阈值时只画豆子颜色。这点交互细节是打印出来看效果的时候才发现的。4.2 平移缩放手势与交互取舍图纸查看器最核心的手势就是双指缩放和单指拖动。Flutter 里现成的 InteractiveViewer 能实现这两个手势但它有一个问题它默认会拦截点击事件导致我无法在“点击格子标记已完成”和“拖动查看图纸”之间自如切换。我当时的取舍是自己用 GestureDetector 包一层手势逻辑而不是用 InteractiveViewer。GestureDetector( onScaleStart: _onScaleStart, onScaleUpdate: _onScaleUpdate, onScaleEnd: _onScaleEnd, onTapUp: _onTapUp, child: CustomPaint( painter: BeadBoardPainter(...), size: Size.infinite, ), )手势状态我用三个变量维护_scale缩放倍数、_offset平移偏移、_lastFocalPoint上一次手势焦点。在onScaleUpdate里计算新的缩放值然后以焦点为中心缩放同时更新偏移量。void _onScaleUpdate(ScaleUpdateDetails details) { setState(() { _scale (_scale * details.scale).clamp(1.0, 16.0); _offset _offset details.focalPoint - _lastFocalPoint; _lastFocalPoint details.focalPoint; }); }单个手指拖动时details.scale保持为 1所以上面的逻辑退化成纯平移跟 ScrollView 的拖动手感很像。双指捏合时缩放以两指中心为锚点整体非常跟手。这里最容易出错的是缩放后点击格子的坐标换算。屏幕上一个点击位置要换算成图纸里的行列必须先把平移偏移减掉再除以缩放倍数最后除以格子尺寸。我当时漏了一步结果放大到 4 倍时点击的位置全偏了。4.3 已拼标记与撤销操作“标记已完成”是拼豆玩家最需要的功能但也是交互上最容易误触的功能。我最终的设计是默认状态下单击格子切换该格子的完成状态已完成的格子会叠加一个半透明绿色蒙层。在设定里可以开启“防误触模式”开启后单击只做预览需要长按才切换状态。已完成格子的存储我用Setint存的是格子的线性索引row * width col。这个设计有两个好处一是判断某个格子是否完成非常快set.contains(index)是 O(1)二是状态保存和读取非常方便直接转成 List 塞进 JSON 即可。进度统计直接从这个 Set 生成完成数 set.length总数 width * height百分比 两者相除。头部用一行文字展示并画一个简单的 LinearProgressIndicator。这个小东西看着不起眼但用户拼到一半抬头看一眼进度体验提升非常明显。4.4 性能优化别让 2000 个豆子拖垮帧率CustomPainter 已经解决了一部分性能问题但真正做大图比如 58 × 58也就是 3364 个格子的时候还是有可感知的卡顿。我做了三个优化按收益排序RepaintBoundary 隔离。把 CustomPaint 单独包在 RepaintBoundary 里避免父级其他 UI如顶部进度栏、底部按钮重绘时连带整个图纸重新绘制。离屏缓存。当缩放和偏移没有变化时把上一次绘制结果缓存成 Picture 或 ui.Image下次直接绘制缓存而不是重新画 3000 个圆角矩形。这个优化在快速缩放时效果立竿见影。isComplexHint 提示。给 CustomPainter 的构造加上isComplex: true和willChange: false帮助 Flutter 引擎缓存绘制结果。CustomPaint( isComplex: true, willChange: false, painter: BeadBoardPainter(...), )实测下来优化后在普通中端 Android 真机上58 × 58 图纸的缩放平移都能稳定在 60 帧鸿蒙真机表现也差不多。所以 Flutter 的 Canvas 性能对于这种应用来说完全不是瓶颈瓶颈基本都在“你有没有正确地避免不必要重绘”。5. 鸿蒙真机适配时遇到并解决的三个兼容性问题如果说前几章是通用技术那这一章就是鸿蒙专属的“体验课”。跨平台框架最怕平台行为差异鸿蒙和 Android 虽然底子接近但在 Flutter 运行时上还是有几个让我头疼的问题。5.1 滚动事件死在手势竞技场里的排查链路先描述问题应用里有一个“图纸库”列表页用了 ListView 展示多份图纸。每个图纸卡片是一个可点击的 InkWell。在 Android 上一切正常上滑下滑、点进详情都很流畅。到了鸿蒙真机上列表滚动时经常出现“滚不动”的情况手指一放列表就停住偶尔还会触发点击而不是滚动。排查过程先在鸿蒙的开发者选项里打开“指针位置”确认触摸事件确实上报上来了问题不在硬件。在 Flutter 侧给 ListView 的 Item 加onPointerDown日志发现事件能到 Item但 ListView 的滚动被“抢走”了。给 ListView 显式指定physics: const BouncingScrollPhysics()问题依旧。最后怀疑是手势竞技场GestureArena的问题。跟 Android 原生相比鸿蒙的 ArkUI 手势系统对 Flutter 的滚动有额外的拦截逻辑尤其是当 ListView 的子项里有GestureDetector的 tap 手势时tap 和 drag 的竞争在鸿蒙上更偏向 tap。解决方式是用RawGestureDetector自定义竞技场或者在 ListView 外层禁掉和子项的竞争。我的实际做法更简单在卡片内部不用GestureDetector包整个卡片而是把点击区域收敛到一个明确的 IconButton 上ListView 自身的滚动就不跟 IconButton 抢手势了。ListView.builder( physics: const ClampingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), itemBuilder: (context, index) { return ListTile( title: Text(pattern.name), trailing: IconButton( icon: const Icon(Icons.chevron_right), onPressed: () _openPattern(index), ), ); }, )这个问题在 Android 上没有复现所以如果你只在 Android 上测试根本发现不了。跨平台开发最隐形的成本就在这里——平台行为差异只会在真机上暴露。5.2 字体缩放把色号数字挤出去的适配鸿蒙系统的“显示大小”和“字体大小”是可以独立调节的很多用户会调大字体。Flutter 渲染时会通过MediaQuery.textScaler拿到这个缩放值应用到所有 Text 组件上。我的图纸色号标注是在每个格子里显示一个很小的数字比如 “12” 代表 12 号色。当系统字体缩放调大后这个数字会把整个格子撑爆数字溢出到格子外面非常难看。问题排查非常简单我在格子的 debug 绘制里把Text的约束矩形打出来发现它的可用宽度是cellSize但字体缩放后文本宽度超过了这个值。解决方案是在色号数字外面包一层 FittedBox让文字在可用空间内自动缩而不是被系统字体大小牵着走。SizedBox( width: cellSize, height: cellSize, child: FittedBox( fit: BoxFit.scaleDown, child: Text( _shortLabel(colorId), style: TextStyle(fontSize: 10), ), ), )注意这里我没有粗暴地在 MaterialApp 里设置textScaler: TextScaler.noScaling。因为用户调大字体是他的合理需求尤其是年纪偏大的拼豆爱好者。我只让色号数字这种“必须精确显示在格子内”的文本做自适应缩放其他比如说明文字、设置菜单还是跟随系统字体。这种细节上的取舍用户嘴上不说但用起来会明显觉得“这个 App 做得很用心”。5.3 打包 HAP 之后资源路径和大图加载策略Flutter 打包鸿蒙应用产物不是 APK 而是 HAP。我在第一次打包签名后装到真机上发现图纸预览图加载不出来日志报的是资源路径找不到。原因很简单Flutter 的rootBundle.load在 Android 和 Windows 上能正确解析 assets 路径但鸿蒙 HAP 包里的资源结构跟 APK 不完全一样。尤其是我把图纸 JSON 放在assets/patterns/目录下打包后路径会被重新组织。处理办法是在鸿蒙入口处做一个路径归一化如果路径以flutter_assets开头就直接用否则拼接flutter_assets/前缀。另外大图加载策略不只是路径问题。拼豆玩家经常导入非常高分辨率的图片当背景参考图HAP 包内的图片资源如果直接全量解码内存很容易爆。我用 Flutter 的Image提供cacheWidth参数限制解码时最大尺寸这样大图在加载时只解码到屏幕实际需要的分辨率内存占用可以降一个数量级。Image.asset( assets/reference.jpg, cacheWidth: (MediaQuery.of(context).size.width * 2).round(), fit: BoxFit.contain, )6. 从查看器到常用工具后续还能怎么扩这个应用做到现在已经不是一个“能看图纸的小工具”了而是拼豆玩家的主力工作台。最后聊聊后续几个值得扩展的方向以及我个人的一些体会。6.1 图纸来源和在线分享目前应用支持本地 JSON 导入、二维码分享。二维码内容可以是一段压缩后的 JSON也可以是一个远程 URL。考虑到拼接图纸文件很小一般几十 KB我建议 URL 方案方便后续更新和统计。图纸列表页可以挂一个简单的内容接口按分类拉取图纸列表让用户可以浏览别人分享的图纸。网络请求用 Dart 的http包就够了不需要引入太重的东西。6.2 从图片识别图纸的大致思路玩家手上最多的还是图片图纸。做一个“图片转图纸”功能流程大致是取色板 → 图片做透视校正 → 按网格区域采样颜色 → 用最近邻或聚类算法匹配到调色板里最近的色号 → 生成 JSON。这个功能靠移动端算力也能跑但放到 Windows 桌面端体验会更好。匹配算法关键点是颜色距离不能用 RGB 空间直接算欧氏距离因为人对颜色的感知不是线性的。建议先把 RGB 转到 Lab 色彩空间再算色差这样匹配出来的色号才和肉眼判断一致。这一块要是展开又是另一篇长文但对拼豆工具来说绝对是拉开差距的功能。6.3 我对这类跨平台工具应用的一点体会做完整趟下来我最大的感触是Flutter 在鸿蒙上的生态已经没有很多人想象中那么“不可用”了但对平台差异的敬畏心不能丢。渲染层被 Flutter 抹平了但手势行为、字体缩放、资源路径、打包签名这些平台细节仍然需要真机逐个验证。尤其是鸿蒙这种还在快速演进的系统不同版本之间的行为差异可能比 Android 碎片化还明显建议每轮发版前至少覆盖手机和平板两类真机。另外做工具类应用不要一上来就堆功能。拼豆图纸查看器最核心的价值就是“看得清、拖得动、标得准”这三个基础体验打磨到极致比加十个花哨功能都管用。我后续再扩展方向也会围绕“让拼豆过程更专注”来加而不是为了功能列表好看。
返回列表