ARTICLE DETAIL

资讯详情

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

Flutter跨平台开发实战:从石料档案App看鸿蒙适配与性能优化

Flutter跨平台开发实战:从石料档案App看鸿蒙适配与性能优化 篆刻这行有个很现实的问题刻刀和石头都好说但“记录”这件事一直很原始。石料从哪里来、什么品种、多大尺寸、切出过几块料、刻到第几步多少人还在用本子和脑子在记。松散的纸质记录换个地方就丢了手机相册里的照片过几个月根本对不上号。我接了个小需求就是给一个篆刻工作室做石料档案管理要求一个App能同时装在安卓、Windows和鸿蒙设备上。最后这个项目是用 Flutter 跨平台方案落地的过程中踩了不少适配的坑尤其是鸿蒙这块。这篇内容就把整个开发流程拆开讲从为什么选 Flutter到数据模型怎么设计再到鸿蒙适配和常见报错都是实际动手验证过的东西。这个教程适合两种人一种是会用 Flutter 但想试试鸿蒙适配的开发者另一种是完全没有移动端基础、想给自家小作坊做个工具类App的手艺人。不需要你有多深的编程底子但最好有基本的 Dart 语法认知。文中的代码片段可以直接抄但更重要的是后面那些“为什么这么写”的逻辑。1. 项目整体设计与技术选型思路1.1 为什么最终选了 Flutter 而不是其它跨端框架跨端开发的选择说白了就是三选一自绘引擎、原生桥接、Web 容器。React Native 走的是桥接原生控件的路子思想没问题但每次原生升级都可能带来适配成本Electron 和 Tauri 这类方案本质上是把 Web 页面包进桌面壳里开发效率高但移动端性能和内存占用不理想在手机上跑一个小工具App总觉得笨重。Flutter 和它们都不太一样。Flutter 用 Skia/Impeller 引擎自己绘制每一个像素不依赖系统原生控件。这意味着同一套 UI 代码在安卓、iOS、Windows、Linux、鸿蒙上渲染出来的效果几乎是一致的。做篆刻记录这种工具类App界面不需要多华丽但不同设备上看起来不能有“换了个人做”的割裂感。Flutter 最打动我的一点是你在电脑上调试好的布局到手机上依然是你调试的那个样子。另外Flutter 的单代码库对这类小型项目特别友好。一个石料记录应用撑死也就是三五个页面加一个本地数据库用一个主力语言 Dart 写 UI、写业务、写数据库操作比“前端写一套 安卓写一套 Windows 再写一套”省下的人力不是一点半点。团队如果只有两三个人这是最现实的选择。1.2 鸿蒙为什么值得纳入目标平台鸿蒙系统这几年在手机、平板、智能设备上的覆盖率越来越高哪怕只是作为一个新增的发型渠道也值得纳入目标平台。对于篆刻工作室这种线下生意客户很可能是华为手机用户你要是不支持鸿蒙那一部分用户就只能干瞪眼。从技术层面讲Flutter 适配鸿蒙的路径已经走通了。OpenHarmony 社区维护着 Flutter 的分支实现让 Flutter 引擎能够跑在鸿蒙设备上打包产物就是鸿蒙标准的 hap 安装包。你在 Flutter 工程里加一个 ohos 平台剩下的开发模式和你写安卓端几乎一模一样页面照常写、插件能复用就复用遇到缺失的原生能力用平台通道补上。这也是我敢接这个需求的原因——成本是可控的不是从零开始学一套 ArkUI。需要提前说明的是Flutter 上鸿蒙和原生 ArkUI 开发不是二选一的替代关系。如果你只做鸿蒙一个平台直接学 ArkUI 更顺但如果你要同时覆盖多个终端Flutter 这套“一套代码、多端分发”的模式性价比就体现出来了。1.3 篆刻石料记录应用的核心需求拆解写代码前先把需求想明白比写代码本身重要得多。我跟工作室老板聊完整理出的核心需求其实很朴素模块功能说明优先级石料档案记录名称、品种、产地、购入日期、尺寸、重量、纹理特征、初始照片高图片管理原石、打坯、细雕、成品等多阶段拍照支持压缩和缩略图高刻制进度状态流转待刻、打坯、细雕、抛光、完工附带每次操作的备注高检索筛选按品种、产地、当前状态、关键词组合筛选中数据备份数据库一键导出换设备不丢档中本地优先所有数据存在本机不依赖网络断网也能用高这个需求清单有一个隐含的架构决定数据库用本地 SQLite不做服务端。工作室的实景环境经常在地下室或库房网络不稳定让师傅们等云端同步不现实。本地数据库单文件存储再用导出功能定期备份反而最可靠。这个“本地优先”的思路贯穿了整个项目后续所有选型都围绕它展开。2. 从零搭建工程目录、分层与组件通信2.1 用 Android Studio 创建 Flutter 工程时最容易忽略的事网上搜“如何 AS 创建 Flutter 项目”出来的教程大同小异装插件、新建工程、等编译。但实际动手时有几个细节特别容易坑人。首先Flutter SDK 和 Android Studio 的版本必须对得上。建议先跑一遍flutter doctor把环境问题全部清干净再开始建项目。我看到过太多人跳过这一步最后卡在各种 SDK 缺失的报错上浪费一下午。创建工程时有一个 Platform 勾选界面默认勾了 Android 和 iOS。做鸿蒙适配的话如果你用的是社区适配版 Flutter支持 ohos 平台这里会多出一个 OHOS 选项记得勾上。命令行的创建方式也可行flutter create --org com.sealstudio --project-name seal_archive --platforms android,ios,ohos .注意--org要倒着写域名这决定了应用包名后面改起来很麻烦。项目名用下划线命名不要用驼峰Dart 包名规范不允许。另外工程路径里不要出现中文和空格。我见过一个唐诗App项目放在C:\用户\桌面\篆刻\下编译时各种奇怪报错把路径改成纯英文后问题就消失了。2.2 工程目录怎么分数据才不乱很多 Flutter 新手把一个应用全部塞进 main.dart页面、数据、工具函数堆在一起两三千行之后改一个字段得全局搜索。做这个小项目我用了按 feature 分层的结构lib/ main.dart // 入口 app.dart // MaterialApp 与全局主题 core/ database/ app_database.dart // 数据库初始化与表结构 utils/ date_utils.dart features/ archive/ models/ seal_stone.dart // 石料数据模型 pages/ archive_list_page.dart archive_detail_page.dart archive_edit_page.dart widgets/ stone_card.dart archive_controller.dart // ChangeNotifier 状态管理 photo/ picker/ photo_picker.dart viewer/ photo_viewer.dart shared/ widgets/ empty_view.dart confirm_dialog.dart这个结构的核心思想是每个业务模块archive、photo内部自己管自己跨模块需要的东西通过独立文件或依赖注入传入。core 里放不依赖业务的基础能力数据库、工具函数shared 里放通用组件。这样做的好处是后期换数据库、加新功能不会牵一发动全身。我特别想把一个经验分享出来数据模型和页面文件分开。石料档案这个模型在列表页、详情页、编辑页都会被使用如果模型文件散落在各个页面目录里引用关系会很乱。模型统一放在features/archive/models/下页面只管调用能省掉很多不必要的 import 错误。2.3 组件通信与状态管理小项目别想太复杂Flutter 组件通信是个老话题面试还常考。但落到这个项目上思路很简单先想清楚哪些数据是局部的哪些是全局的。通信方式适用场景本项目用途构造参数传值父→子单向传递页面跳转带参数列表页→详情页传入石料 ID回调函数子→父传事件删除确认弹窗、列表项点击InheritedWidget数据从根部向下共享一般不直接用Provider 底层用Provider跨页面共享可变状态石料列表刷新、筛选条件共享Bloc/Riverpod复杂异步状态流本项目没用杀鸡不用牛刀最终选了 Provider ChangeNotifier。原因很简单石料列表在列表页展示但编辑页保存之后列表页需要刷新这种“跨页面的状态同步”用 ChangeNotifier 非常顺手。class ArchiveController extends ChangeNotifier { ListSealStone _stones []; ListSealStone get stones List.unmodifiable(_stones); Futurevoid load() async { _stones await AppDatabase.instance.getAllStones(); notifyListeners(); } Futurevoid saveStone(SealStone stone) async { await AppDatabase.instance.insertStone(stone); await load(); notifyListeners(); } }然后在 widget 树顶部用ChangeNotifierProvider包一层任何页面用context.watchArchiveController()就能拿到最新的列表数据并且自动触发 rebuild。这套模式对中小型 Flutter 应用来说是性价比最高的选择。3. 核心功能拆解从一块“石头”到一本“档案”3.1 石料数据模型与本地数据库选择先定义数据模型。石料档案的字段不能拍脑袋要覆盖工作室实际关心的信息class SealStone { final int? id; final String name; // 名称比如“青田封门青” final String variety; // 品种分类 final String origin; // 产地 final double weight; // 重量克 final double length; // 尺寸长 final double width; // 尺寸宽 final double height; // 尺寸高 final String texture; // 纹理/特征描述 final String status; // 当前刻制进度状态 final DateTime boughtDate; // 购入日期 final String coverPhotoPath;// 封面原石照片路径 final String notes; // 备注 }数据库直接选了 SQLite理由我在需求拆解时说过本地单文件、稳定可靠、备份就是一个文件拷贝。Flutter 侧用的是 sqflite 生态鸿蒙端用社区适配的实现API 基本兼容切换成本很低。建表语句如下CREATE TABLE stones ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, variety TEXT, origin TEXT, weight REAL, length REAL, width REAL, height REAL, texture TEXT, status TEXT DEFAULT 待刻, bought_date TEXT, cover_photo_path TEXT, notes TEXT );这里有一个重要细节日期字段不要存成时间戳整数直接存 ISO 字符串。时间戳可读性差而且日后跨平台格式化时还可能有时区偏差ISO 字符串排序、显示都更友好。开发调试时我强烈建议用 DB4S 这个开源工具直接打开 SQLite 文件查数据。App 运行后把数据库文件导出到电脑用 DB4S 打开表结构和数据一览无余比写在代码里的调试日志直观太多。而且 DB4S 是跨平台的Windows、macOS、Linux 都能用配合 Flutter 开发恰好合适。3.2 拍石料照片路径、压缩与权限拍照这块流程看着简单点按钮、调相机、存照片、显示。实际上有三个坑权限声明、路径选择、图片压缩。拍照或选相册我用了 image_picker 生态这个插件的问题在于返回的是临时缓存目录里的文件不及时处理会被系统清掉。正确的做法是把图片复制到应用的文档目录下再删除临时文件。FutureString pickAndSavePhoto() async { final picker ImagePicker(); final XFile? picked await picker.pickImage( source: ImageSource.camera, imageQuality: 85, maxWidth: 1920, ); if (picked null) return ; final appDir await getApplicationDocumentsDirectory(); final fileName stone_${DateTime.now().millisecondsSinceEpoch}.jpg; final savedPath ${appDir.path}/$fileName; await File(picked.path).copy(savedPath); return savedPath; }压缩参数是实测出来的原图动辄 5MB 以上存储空间倒是小事关键是列表页加载缩略图时会反复解码大图内存肯定吃不消。我在拍照时用maxWidth: 1920配合imageQuality: 85做一次压缩单张压到 300KB 左右画质对展示足够。权限是个大坑。Android 上要在 AndroidManifest.xml 里写相机权限鸿蒙上要改module.json5声明类似ohos.permission.CAMERA的权限而且运行时权限弹窗的逻辑两个平台也不完全相同。适配时我的经验是把权限请求放到页面初始化时主动触发不要等到用户点拍照才弹窗减少一步的慌乱操作导致的拒绝。3.3 检索、筛选与输入防抖石料档案一多检索功能就要顶上来。这个项目的检索条件有三个关键词模糊搜索匹配名称、纹理、备注、品种下拉筛选、状态筛选待刻/打坯/细雕/抛光/完工。刚开始我写的是每次输入框变化就立刻查数据库结果非常卡——因为拍照多了之后数据库里有几百条记录频繁模糊查 LIKE 对移动端来说还是有压力的。解决方案是加一个防抖停止输入 400 毫秒后再执行筛选。Timer? _debounce; void onSearchChanged(String keyword) { if (_debounce?.isActive ?? false) { _debounce?.cancel(); } _debounce Timer(const Duration(milliseconds: 400), () { controller.filter(keyword: keyword, variety: _selectedVariety, status: _selectedStatus); }); }为什么用 Timer 而不是把查询丢到 Future 里就算完因为用户连续输入时之前的查询很可能还没返回新结果就又被触发了产生竞态大脑。防抖的本质是合并高频事件只保留最后一次对这类输入场景是正确解法。筛选条件如果很多不要每一个都单独 setState可以把所有筛选条件做一个Filter对象controller 里监听这个对象的变化一次通知刷新。省代码也避免多次重建列表。3.4 列表页、详情页与下拉刷新实践列表页是全 App 的门面我用ListView.builder渲染石料卡片每个卡片左上角是缩略图、右侧是名称和状态标签。状态标签用了不同颜色区分待刻灰色、打坯蓝色、细雕橙色、完工绿色。颜色语义可以帮助师傅们扫一眼就知道哪些石头还没动。下拉刷新用的是 Flutter 自带的RefreshIndicator套在 ListView 外面即可。这里要留个心眼RefreshIndicator 的 onRefresh 回调必须返回一个 Future并且要等数据加载完才结束。如果回调里直接执行一个没有 await 的耗时方法下拉动画会立即收回用户根本不知道数据在加载。详情页我用了CustomScrollViewSliverAppBar实现“头部照片收起、内容上滑”的效果。照片放大查看用PhotoView插件支持双指缩放这对查看石料纹理细节是刚需。列表页到详情页的跳转加了一个 Hero 动画让封面缩略图平滑放大成头部大图。这个动画成本极低但体验提升非常直观值得写进每一个图片型应用里。4. 鸿蒙适配从能跑通到真正可用4.1 当前 Flutter 上鸿蒙的主流适配路线Flutter 跑鸿蒙并不是你装个官方 Flutter SDK 就能直接编译出 hap。目前主流路线是使用 OpenHarmony 社区维护的 Flutter 分支这个分支在引擎层做了鸿蒙的适配支持把 Dart 代码和引擎打包成鸿蒙可识别的产物。开发流程大致是安装 DevEco Studio配置好 OpenHarmony SDK。使用社区 Flutter 分支支持 ohos 平台。在 Flutter 工程中启用 ohos 平台支持。最后通过 DevEco 打开工程里的ohos目录完成 hap 打包和签名。整个过程里Flutter 应用本身的代码几乎不需要为鸿蒙单独改写UI 照常由 Flutter 引擎绘制。真正要动的地方是原生能力调用和权限声明也就是 4.2 要讲的差异点。有一点需要静下心认清鸿蒙适配目前还在快速迭代插件的原生实现不像 Android 生态那么齐全。选插件时第一反应应该查一下“是否支持 ohos”不支持就用平台通道自己封装别硬等某个插件适配。4.2 迁移到鸿蒙后必改的 5 类差异我梳理下实际移植时必须要处理的差异做成表格放在最前面后面详细交代每一项差异点Android 端鸿蒙 ohos 端建议做法权限声明AndroidManifest.xmlmodule.json5双端各维护一份权限列表文件存储路径内部存储/sdcard应用沙箱路径一律用 path_provider 获取真实路径平台通道Channel 名称自定义同名 Channel端侧实现不同统一 Channel 命名各端实现图片选择/相机有现成插件插件缺失时应自封装优先找 ohos 适配版插件数据库sqflite 正常用兼容 ohos 的分支避免使用含原生扩展的桌面插件权限声明这件事Android 和鸿蒙各管各的主工程里你要同时维护两份原生配置。刚开始我以为 Flutter 应用迁移到鸿蒙后连权限都不需要管结果一调用相机直接崩日志提示权限缺失把相机权限写进 module.json5 之后才正常。文件路径是另一个隐形坑。Android 上很常见的/storage/emulated/0/...这类绝对路径在鸿蒙的沙箱机制下根本不存在。凡是涉及文件读写老老实实用path_provider或者它支持 ohos 的等价实现获取应用文档目录不要手写路径。还有一个不是很大但会让人懵的差异布局体系。鸿蒙 ArkUI 里有一套自己的声明式语法用 RelativeContainer、Flex、Tabs 这类组件。很多从鸿蒙原生转过来的开发者以为 Flutter 里也得用这些其实不然——Flutter 应用内依然是 Row、Column、ListView 那套组件体系ArkUI 的布局只在鸿蒙原生页面里才会用到。两者互不干扰同一个应用可以一部分页面用 Flutter 写一部分用 ArkUI 原生的 PlatformView 嵌入怎么组合完全看你需求。4.3 构建配置与 hap 打包当 Flutter 工程需要鸿蒙产物时流程和你熟悉的 Android 打包很相似flutter pub get flutter build hap --release这一步会在build/ohos下生成 hap 的中间产物。最终签名和发布需要打开ohos目录工程在 DevEco Studio 里配置签名证书后打包。构建过程中有个高频问题Flutter 引擎在鸿蒙侧打成一个类似 AAR 的模块很多人会遇到“flutter aar 构建失败”的报错。这类问题十有八九是 SDK 版本不匹配或者缓存没清干净引起的。我的建议是先跑flutter clean清掉所有缓存再把 flutter SDK 切换到稳定版本重新 build。如果还报错把完整的构建日志贴出来看具体卡在哪一步不要东改西改。签名方面调试阶段 DevEco 会自动用调试证书发布到市场或安装到多台设备则需要配置正式签名。这个和 Android 的 keystore 类似提前在 DevEco 里配好不要等到发版前一天才想起来。4.4 用 PlatformView 和 MethodChannel 补上原生能力Flutter 的插件生态再丰富鸿蒙上的原生能力总有一两个没有现成实现。这时候平台通道就要出手了。比如我需要调用一个设备震动反馈提醒师傅“照片保存成功”原生侧写一个极简的方法就够了static const MethodChannel _channel MethodChannel(seal_archive/feedback); Futurevoid vibrate() async { try { await _channel.invokeMethod(vibrate); } on PlatformException catch (e) { debugPrint(vibrate failed: ${e.message}); } }鸿蒙原生侧在对应页面的生命周期里注册同样的 Channel 名称实现 vibrate 方法调用系统的震动接口。这里最容易犯的错是Dart 侧 Channel 名称和原生侧不一致或者原生侧没在正确的时机注册。Channel 名称是唯一的“通信协议”两端名字对上号方法名也对上号数据就能传。PlatformView 则用于嵌入原生控件。Flutter 里有些场景比如复杂地图、特定扫描组件用自绘实现成本过高就适用原生 View 嵌入。鸿蒙的 PlatformView 能力和 Android 类似但接口有差异使用前一定先看对应适配分支的文档别把 Android 的实现方式直接搬过去。5. 高频报错与性能优化实录5.1 构建与运行报错速查表做跨端项目报错十有八九不是写错代码而是环境或配置问题。我把这阵子遇到的高频问题整理成一张表照着排查能省很多时间现象大概率原因解决方法Flutter 新建项目后跑不起来Flutter SDK 版本老 / 设备未识别跑flutter doctor升级 SDK重启模拟器Gradle 插件告警提示不要用 apply script工程 build.gradle 用了旧式 apply按提示改用 plugins 块声明E/flutter ... dart_vm_initializer.cc(41): Unhandled Exception运行时异常未被捕获常见为 null 检查崩溃看完整 StackTrace定位到具体行处理空值逻辑flutter aar 构建失败鸿蒙集成时引擎模块路径或缓存异常flutter clean后重新构建检查 SDK 分支版本平台通道 invokeMethod 返回异常Channel 名称不一致或原生侧未注册核对两端 Channel 名称和方法名依赖包版本冲突pubspec.lock 中间版本存在兼容问题固定插件版本号尽量用稳定版E/flutter ... Unhandled Exception应该是出现频率最高、也最让人头疼的。这个报错没有任何业务信息提示必须看下面的堆栈。我做项目时有次列表页下拉刷新闪退堆栈指向了一个图片文件的 File 构造原因是图片被手动删除后文件路径没更新数据库里还是旧路径一刷新就崩。周末排查了一下午教训就是凡是涉及外部文件的路径字段读写时要主动判断文件是否存在文件缺失就给默认占位图。5.2 Future.then 与微任务别再凭感觉写异步写 Dart 异步代码最容易被问到的就是“Future 的 then 回调是不是放到微任务队列”。答案是then 回调会被调度到微任务队列执行但它的前置条件是要等到上一个 Future 完成。稍微展开一下。Dart 事件循环有两个队列事件队列Event Queue和微任务队列Microtask Queue。事件队列处理外部事件微任务队列优先级更高每次事件队列的顶部任务执行完毕后会先把微任务全部清空再处理下一个事件。Future 的回调then、catchError、finally 里的代码逻辑上会被放进微任务队列但不是同步立即执行而是在当前事件处理完后的微任务阶段。有一个很容易搞混的点Future构造器里的代码和Future后紧跟的.then里的代码执行时机完全不同。void main() { print(1); Future(() print(2)); // 这个异步任务进入事件队列定时优先级为0 Future.microtask(() print(3)); // 微任务 print(4); } // 实际输出顺序1、4、3、2为什么 3 比 2 先输出因为Future.microtask直接把任务放进微任务队列而Future()默认构造器注册的是事件队列任务相当于 Timer(Duration.zero)。事件循环处理完当前同步代码后第一件事就是清空微任务所以 3 先走事件队列里的 2 后面才执行。这对前端界面意味着什么耗时操作不要全丢到 then 链条里尽量在独立 Isolate 做重活把结果通过 SendPort 传回来。否则即使不卡主线程微任务队列也会被长任务占满界面响应依然会变迟缓。我优化过石料图片批量导入的逻辑把图片压缩放到 compute 里跑界面终于不再转圈。5.3 图片列表内存与滚动性能优化石料档案数量超过 300 条之后列表页如果每张卡片都加载原图内存一定爆。我分两步优化第一步是控制解码尺寸。Image.file显示缩略图时指定cacheWidth参数强制 Flutter 按指定宽度解码这样内存里只保留小图而不是先解码出一张 4000x3000 的图再缩到 100x100。Image.file( File(stone.coverPhotoPath), cacheWidth: 200, fit: BoxFit.cover, )第二步是生成独立的缩略图文件。拍照压缩时同时存一个_thumb后缀的小图列表页读小图详情页读大图。这样列表滚动时 IO 开销也小很多。实测 500 张照片的列表滚动维持在 60 帧不掉帧。另外一个容易忽略的优化是RepaintBoundary。列表项里如果有动画、圆角裁剪、阴影Flutter 会在每次重绘时触发整卡片重绘。用RepaintBoundary包住每个卡片隔离这部分绘制边界滚动性能会有肉眼可见的提升。不需要额外的库一个组件的事值得每个列表都加上。5.4 多终端版本兼容老设备与新 API 共存篆刻工作室的用户设备分布很杂有的是最新旗舰机有的是多年前的老款。Flutter 应用面对这种情况要在工程层面做几个约束minSdkVersion 别设太高。太高会把老设备直接挡在门外但设太低又可能碰到部分 API 不可用权衡下来我习惯设在 24 左右。插件版本锁定。pubspec.lock 里的插件版本建议保留锁定状态不要频繁升级插件。跨平台适配中的插件升级很可能引入不兼容变更为了一个新特性全盘升级老插件不值得。新 API 做运行时探测。某些能力我只在较新系统上启用用Platform.isAndroid加系统版本判断在旧设备上走降级路径比如不显示实时模糊效果、不加高级动画。老设备上最常遇到的不是系统 API 缺失而是可用内存偏低。图片压缩、缩略图策略、禁用不必要的动画本质上都是在帮老设备减轻负担。这个思路应该从第一天写代码就贯穿进去而不是等用户反馈卡顿再补救。鸿蒙适配走过一遍之后我再回头看这个项目最大的感悟是跨平台开发最值钱的资产不是“一套代码跑三个端”这种结果而是你在选型时愿意为“跑得稳”做的那些基础决策。数据模型先想清楚、状态别过度设计、图片从一开始就做压缩、路径永远用系统 API 获取、每个平台通道命名统一——这些看似琐碎的约束最后都换成了“不需要反复修bug”的安静日子。再分享一个压箱底的小技巧给应用加一个“一键导出数据库”的按钮导出的 SQLite 文件名带上时间戳。这个功能花不了 20 行代码但换手机、给别的师傅传数据时你会感谢当初写这个按钮的自己。档案类应用最怕的不是功能少而是数据没了。
返回列表