ARTICLE DETAIL

资讯详情

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

Flutter开发鸿蒙APP实战:从环境搭建到计分器应用打包

Flutter开发鸿蒙APP实战:从环境搭建到计分器应用打包 年初朋友找上来说他们羽毛球俱乐部要办一场内部团体赛以前靠纸质记分牌打完还要翻手机照片补比分想要一个手机上的比赛计分器APP。需求本身一点不难但真正折腾我的是选型队员里一大半人的手机已经是鸿蒙系统以前那个老安卓APK在新设备上装起来越来越别扭。常规思路是学ArkTS重写一版原生鸿蒙应用可一个计分器真的就两三个页面、一个状态机重写一次成本说高不高说低不低重点是以后维护要养两套代码。最后我决定用Flutter框架直接走跨平台鸿蒙开发路线一套Dart代码把Android、iOS、鸿蒙全包进来。项目做下来的整体感受是Flutter上鸿蒙这条路比很多人想象中靠谱但也有一些文档里没写明白的坑。这篇文章就把整个开发流程摊开讲从选型理由、环境搭建、核心状态设计到界面交互、数据持久化再到真机打包。想给团队选型做评估、或者打算用Flutter试水鸿蒙开发的都可以先按这个流程走一遍。1. Flutter上鸿蒙的可行性判断先别急着写ArkTS1.1 OpenHarmony的Flutter适配到底成熟到什么程度先厘清几个概念。我们说的鸿蒙应用目前实际有三条路一条是HarmonyOS NEXT的ArkTS原生开发这是官方主推方向一条是把已有的Web页面包成元服务或者卡片适合轻量场景第三条就是这里要聊的——用Flutter在OpenHarmony上跑跨平台应用。前两条你肯定没少听说但第三条很多人还停留在实验室阶段的印象里其实已经变了。OpenHarmony SIG特别兴趣组维护的flutter_flutter、flutter_engine、flutter_packages三个仓库相当于把Flutter整个链路移植到了鸿蒙生态。flutter_flutter是Dart SDK和工具链flutter_engine是渲染引擎和运行时flutter_packages是常用插件的鸿蒙适配层。我实际用的版本线已经能稳定编译出可在真机上运行的hap包跑复杂页面也没什么问题。当时我把一个带列表、表单、动画的Demo装到鸿蒙手机上连续用了两天没闪退就从心里把它从评估阶段调到了可以开工。当然成熟归成熟它和官方的安卓/iOS支持还是有差距主要体现在三块插件生态还不是所有Flutter插件都有鸿蒙版本需要看联邦插件的适配进度官方Docs里没有鸿蒙平台这一项很多问题要自己去仓库Issue里翻还有调试工具链比Android复杂一点不能用Android Studio那套直接跑得配合DevEco和hdc命令来做。1.2 ArkTS和Flutter站在开发者面前怎么选ArkTS和Flutter谁更流行这种问题其实没有标准答案因为两者根本不是替代关系是不同优先级下的取舍。对比维度ArkTS原生鸿蒙Flutter跨平台鸿蒙系统能力调用最全新特性第一时间能碰依赖社区插件适配进度略有滞后多端覆盖只覆盖鸿蒙一套代码覆盖Android、iOS、鸿蒙、桌面UI一致性跟随鸿蒙设计语言自绘渲染像素级一致团队学习成本需要学ArkTS/ArkUI已有Flutter经验的团队几乎零成本包体积小比原生大几个量级后面细说上架流程最顺畅工具链完整可以上架但要额外对齐审核规则给非技术背景的老板解释这个事情我一般用一句话如果你只需要服务鸿蒙用户就直接ArkTS如果你有存量Flutter代码库或者明确要覆盖多个平台Flutter是性价比最高的中间路线。计分器这种工具型APP恰好是Flutter的舒适区业务逻辑集中、UI不依赖太多系统级控件、动画简单、状态管理可控就算某个插件在鸿蒙上暂时没有适配自己写一个Pigeon桥接也很快。1.3 什么样的APP适合走Flutter鸿蒙这条路不是所有APP都适合用Flutter跑鸿蒙。我的判断标准有三条。第一条不重度依赖系统级服务比如你要做近场通讯、后台长驻、系统设置面板这些地方Flutter社区的鸿蒙插件可能还没覆盖硬做会花大量时间在原生侧编码。第二条UI和交互以自定义为主而不是跟系统原生控件强绑定Flutter自绘引擎的好处在这个场景才体现得出来。第三条团队已经有Dart/Flutter的心智沉淀否则新增一个语言栈本身就是成本。当时评估计分器需求我快速过了一遍计分状态管理、几个按钮、数字动画、持久化历史记录、录音播报比分这些在Flutter侧都有成熟方案鸿蒙适配版本也能找到对应实现。于是果断开工。事实证明这个判断是对的后面遇到的所有坑都没出在这三条判断里。2. 鸿蒙Flutter开发环境准备拉仓库、装SDK、建工程一次搞定2.1 三件套仓库的获取与版本匹配这套环境搭建最大的特点是你不能直接下载官方稳定版Flutter就开跑而是要走OpenHarmony SIG维护的发行线。我用的版本是3.7.12-ohos这条线虽然名字里有3.7.12但它包含了很多鸿蒙平台特有的适配代码比如路由注册、平台通道对接这些。拉代码的命令大致是git clone -b 3.7.12-ohos https://gitee.com/openharmony-sig/flutter_flutter.git git clone -b 3.7.12-ohos https://gitee.com/openharmony-sig/flutter_engine.git git clone -b 3.7.12-ohos https://gitee.com/openharmony-sig/flutter_packages.git三个仓库可以放在同级目录这样工具链会自动识别相互的引用关系。这里要提醒一句分支名和版本号会随着社区发布节奏变化你clone的时候最好先去仓库首页看README上写的当前稳定分支是什么不要盲抄命令。比如我现在写的这个分支名过几个月可能就不是最新了。还有一个细节Windows和macOS下开发环境变量配置略有区别。Windows可以正常完成编译但官方对Linux桌面宿主和Windows宿主的预编译引擎包不一定同步发布所以最稳的方式还是自己用flutter_engine仓库执行一次全量编译。这个编译时间在好的机器上大概二十分钟到半小时别中途关断。2.2 DevEco Studio与OpenHarmony SDK的配置要点Flutter侧的引擎有了鸿蒙侧还需要一套原生编译环境。DevEco Studio装的是OpenHarmony版本SDK也要选带编译工具链的版本。打开DevEco后在SDK Manager里把OpenHarmony SDK和对应的工具链装好API版本建议和flutter_engine仓库构建时验证过的版本对齐不对齐可能出现动态库符号找不到的问题。到这一步常见的坑是SDK路径配错。DevEco默认的SDK路径和命令行hdc工具路径不在一起后面要用hdc命令连接真机需要把hdc所在目录加到系统PATH里。Windows下一般是DevEco的Sdk\default\openharmony\toolchains目录。你要是发现hdc命令not found八成就是这个原因。2.3 用flutter create建立工程理解两套工程一套代码的结构环境变量配好后创建工程还是老命令flutter create scoreboard_app但要注意这个命令生成的工程默认没有鸿蒙壳工程目录需要从OpenHarmony SIG的ohos_flutter模板里拷贝或者通过他们提供的脚本初始化。最终工程结构有点像React Native那种原生壳Dart包的混合体scoreboard_app/ ├── lib/ # Dart业务代码重点都在这里 ├── ohos/ # OpenHarmony壳工程用DevEco打开 ├── android/ # Android壳工程保留 ├── ios/ # iOS壳工程保留 └── pubspec.yaml这个结构看着绕其实核心逻辑一句话所有业务和UI都在lib目录写鸿蒙壳工程负责把Flutter引擎加载起来、把Dart入口跑起来、处理系统级事件。壳工程是配置密集区但通常只需要配置一次后续很少动它。2.4 用Hello World验证整条链路新建的Flutter工程在鸿蒙上跑不起来这个现象太正常了别慌。标准动作是先在DevEco里打开ohos目录让它完成Gradle同步再构建entry模块。第一次构建会把flutter_engine编出来的so库打到hap里所以耗时可能比较长。构建完成后用hdc安装hdc list targets hdc install entry-default-signed.hap装上后鸿蒙手机里会出现一个应用图标点开看到Flutter自家那个默认计数器页面就说明整条链路通了。接下来做的第一件事就是把计数器Demo改成我们的计分器页面先跑通业务代码再回头优化。3. 计分器核心状态模型Provider做状态管理组件通信不再绕3.1 先列需求计分器到底要记哪些状态动手写代码之前我把需求摊在桌面上比想象中要多一点当前这局双方各得多少分单局获胜需要多少分羽毛球21分、乒乓球11分这类差异是否采用领先2分获胜规则比如20比20之后必须净胜2分总盘数设置以及双方各赢几盘3局2胜还是5局3胜当前第几局、是否有赛点加分、撤销、换局、整场比赛重置这些操作最后保存一条完整的比赛记录用一张表把这些状态列出来编码的时候心里非常清楚状态类型示例player1Score / player2Scoreint21 / 18player1Games / player2Gamesint2 / 1currentGameint第3局winScoreint21gamesToWinint2winByTwobooltruegameFinishedboolfalsematchFinishedboolfalsehistoryList用于撤销这些状态不是孤立的它们之间有一条完整的状态机逻辑单局比分达到获胜条件时该局结束赢盘数1赢盘数达到总盘数要求时整场比赛结束。这个流程看似简单但如果没有清晰的Model层管理放在Widget里写过几天你自己都改不动。3.2 ScoreModel一个ChangeNotifier撑起全局状态状态管理方案我选了Provider。原因很简单计分器是单页面、中等复杂度状态用Provider的ChangeNotifier模式足够清晰比Riverpod轻比Bloc少了很多样板代码。核心就是写一个ScoreModel类继承ChangeNotifier所有状态变更方法都在里面所有状态变更后调用notifyListeners通知界面刷新。import package:flutter/foundation.dart; class ScoreState { final int p1; final int p2; final int g1; final int g2; final int game; ScoreState(this.p1, this.p2, this.g1, this.g2, this.game); } class ScoreModel extends ChangeNotifier { final String player1Name; final String player2Name; final int gamesToWin; final int winScore; final bool winByTwo; int player1Score 0; int player2Score 0; int player1Games 0; int player2Games 0; int currentGame 1; bool gameFinished false; bool matchFinished false; final ListScoreState _history []; ScoreModel({ required this.player1Name, required this.player2Name, this.gamesToWin 2, this.winScore 21, this.winByTwo true, }); void addScore(int player) { if (gameFinished || matchFinished) return; if (player 1) { player1Score; } else if (player 2) { player2Score; } _history.add(ScoreState( player1Score, player2Score, player1Games, player2Games, currentGame)); checkGameEnd(); notifyListeners(); } void checkGameEnd() { bool p1Win player1Score winScore (player1Score - player2Score (winByTwo ? 2 : 1)) player1Score player2Score; bool p2Win player2Score winScore (player2Score - player1Score (winByTwo ? 2 : 1)) player2Score player1Score; if (p1Win) player1Games; if (p2Win) player2Games; if (p1Win || p2Win) { gameFinished true; _history.clear(); if (player1Games gamesToWin || player2Games gamesToWin) { matchFinished true; } } } void nextGame() { if (!gameFinished) return; currentGame; player1Score 0; player2Score 0; gameFinished false; notifyListeners(); } void undo() { if (_history.isEmpty) return; final last _history.removeLast(); player1Score last.p1; player2Score last.p2; player1Games last.g1; player2Games last.g2; currentGame last.game; gameFinished false; notifyListeners(); } void resetMatch() { player1Score 0; player2Score 0; player1Games 0; player2Games 0; currentGame 1; gameFinished false; matchFinished false; _history.clear(); notifyListeners(); } bool get hasHistory _history.isNotEmpty; }这段代码没有做什么花哨设计就是老老实实把状态机全部显式写出来。有几个点值得说撤销这里我做了个简化把_history在局结束时清空也就是说跨局的撤销是不支持的。原因很简单——跨局撤销会让这局已经打完、赢盘数已经变了这个事实变得不干净如果真的要跨局撤销ScoreState里就要记录更多前置状态甚至要做成真正的Command模式。计分器在实际比赛中裁判按错了也就是当下一秒内撤销跨局撤销几乎用不到。checkGameEnd里的平局逻辑是这样的winScore是基础获胜分winByTwo决定是否需要领先2分。以羽毛球21分制为例21比20这种比分不会触发获胜因为领先差是1真正打到22比20才触发。这不只是代码逻辑也是真实比赛规则。3.3 用Provider共享状态组件通信不再绕Model写好了接下来要让界面上的所有组件共享同一个ScoreModel实例。Provider的核心用法就是一个顶层Provider包住整个App然后在任意子Widget里读取。void main() async { WidgetsFlutterBinding.ensureInitialized(); runApp( MultiProvider( providers: [ ChangeNotifierProvider( create: (_) ScoreModel( player1Name: 红队, player2Name: 蓝队, gamesToWin: 2, winScore: 21, winByTwo: true, ), ), ], child: const ScoreApp(), ), ); }很多刚上手的人会问两个选手的计分卡片没有共同的父Widget怎么通信 答案就是Provider。ScoreModel实例挂在顶层两个计分卡片都是它的子节点读的都是同一个对象。它本质上就是一个全局单例状态只不过用了InheritedWidget做依赖注入让每个组件都能感知状态变化而不需要手动层层传参。具体到Widget里读数据要分清两个API// 触发事件不需要重建自己用read context.readScoreModel().addScore(1); // 读取状态并监听变化重建自己用watch final score1 context.watchScoreModel().player1Score;这个用法差异是Provider设计里最容易被忽略的点。watch会注册一个依赖状态一变当前Widget就重建read不会注册依赖适合在onPressed回调里触发动作。如果搞混了要么界面不更新要么不该重建的按钮频繁重建。当页面里只有一小块UI需要频繁重建时用Consumer把它包起来能减少重建范围。比如比分数字变了只刷数字区域不需要刷整个计分卡片。代码结构上这是很小的优化但到了动画场景界面的流畅度差距就出来了。3.4 计分规则细节平局、赛点与封顶通用计分器不能只服务羽毛球我把规则做成可配置参数后顺手整理了常见球类的分制比赛类型单局获胜分平局规则封顶羽毛球2120平后需领先2分30分封顶乒乓球1110平后需领先2分无封顶排球2524平后需领先2分无封顶网球1540平后需领先2分抢七局特殊处理计分器不是裁判规则系统所以网球那种15、30、40的计分结构我没有在这个版本里做。如果后续要支持网球ScoreModel需要加一个setScore之类的接口甚至引入专门的TennisScoreModel做继承扩展。这属于可扩展点当前版本不必硬塞进去不然为用不到的规则付出复杂度不值得。赛点的判断也值得一提。按规则当一名选手离赢下当前局只差1分且满足获胜条件时就是赛点。代码里判断是领先方分数等于winScore-1且领先优势满足平局规则同时当前局未结束。因为Model持有全部比分数据界面上拿赛点状态非常容易做成一个属性即可。在羽毛球场景中这个功能使用频率极高裁判打分时会不断被问有没有赛点。4. 界面搭建与交互细节大比分显示、防误触、动画反馈4.1 整体布局比分页的骨架计分器界面核心就是三个区域上半部分展示局数和大比分中间是两个选手的比分大卡片下半部分是操作按钮区。我用Column加Expanded做的弹性布局所有数字用超大字体确保手机放在桌上、裁判一眼扫过去就能读出比分。class ScoreboardPage extends StatelessWidget { const ScoreboardPage({super.key}); override Widget build(BuildContext context) { final model context.watchScoreModel(); return Scaffold( backgroundColor: const Color(0xFF1A1F2E), body: SafeArea( child: Column( children: [ _MatchHeader(model: model), Expanded( child: Row( children: [ Expanded(child: _PlayerCard( name: model.player1Name, score: model.player1Score, games: model.player1Games, onScore: () context.readScoreModel().addScore(1), direction: PlayerDirection.left, )), const _CenterDivider(), Expanded(child: _PlayerCard( name: model.player2Name, score: model.player2Score, games: model.player2Games, onScore: () context.readScoreModel().addScore(2), direction: PlayerDirection.right, )), ], ), ), _ActionBar(model: model), ], ), ), ); } }比分大数字我用了固定大字号加FittedBox做自动缩放这样即使打到30比28这种两位数也不会因为数字变宽顶出卡片边界。每张卡片内部是上下结构上面名字、中间大比分、下面本局赢盘数层次清楚。中间的CenterDivider用了一条垂直渐变线视觉上把两名选手分隔开又不显得生硬。4.2 大按钮与可用性设计加分按钮是整个APP里最重要的交互元素。比赛最忌讳的就是裁判低头看屏幕找不到按钮或者手一抖按错了。我的处理方式是按钮高度至少72颜色对比强烈左边选手用暖色调、右边选手用冷色调并且点击区域是整个卡片而不是一个小图标。防误触设计也是比赛场景的刚需。我只做了一层简单防护在一个选手已经赢了当前局gameFinished的情况下两个加分按钮都变成半透明状态点击只会触发换局逻辑而不是继续加分。另外每次加分后按钮没有做隐藏动画保持原有位置避免裁判肌肉记忆失效。真正按错的时候靠撤销。在动作栏放一个撤销按钮旁边显示可撤销步数没有操作历史时按钮置灰。这样裁判可以放心狂按加分按错了一键回去。4.3 比分变化的动画与音效反馈加分反馈做了两层视觉效果和声音提醒。视觉上我用AnimatedSwitcher配合Key让比分数字变化时做一个轻微的过渡效果。比分从20跳到21数字会快速上移淡出再出现新数字动效控制在300毫秒内不会拖沓。AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) { return FadeTransition( opacity: animation, child: SlideTransition( position: TweenOffset( begin: const Offset(0, 0.1), end: Offset.zero, ).animate(animation), child: child, ), ); }, child: Text( $score, key: ValueKey(score), ... ), )声音上用audioplayers播放一个很短的按钮提示音裁判不需要看屏幕就知道自己的操作已经生效。这里有一个鸿蒙适配的小坑audioplayers本身在OpenHarmony上的适配可能不全有的版本需要切换后台播放模式。我的做法是异常捕获做兜底提示音响不起来不影响计分主流程不能让一个音效插件拖垮核心功能。4.4 横屏优先与屏幕常亮实体比赛计分器通常摆在桌面竖屏手机反而别扭。所以我对横屏做了优先适配同时锁屏常亮不能用SystemChrome那套直接解决因为鸿蒙上Flutter的SystemChrome实现有限常亮能力我用了wakelock_plus插件它在OpenHarmony的联邦适配里已经有对应实现。SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]);代码写完后实测横屏显示效果比竖屏舒服得多左边选手对应屏幕左侧按钮右边选手对应屏幕右侧按钮天然形成镜像操作。真到移动端用户使用的时候他侧过手机也很自然。这个设计在评审会上被俱乐部负责人特别夸了一句说这APP一眼就知道谁得分了。5. 把比赛记录存下来Hive轻量持久化方案5.1 为什么选Hive而不是SharedPreferences计分器要保存历史比赛记录用来赛后复盘和俱乐部积分统计。这个需求可以用SharedPreferences硬拼也可以用数据库但这两个方案在鸿蒙适配链路里都各有麻烦。我最后选了Hive理由很实在第一Hive是纯Dart实现本地存储不依赖原生组件这意味着在鸿蒙平台上它少了一层原生插件适配的不确定性。SharedPreferences在Flutter里走的是PlatformChannel鸿蒙上的实现就要依赖社区适配进度而Hive直接跨过这层跑得稳。第二Hive支持对象模型直接存储我能把一个MatchRecord对象整个存进去读取时也是对象出来不需要手写一堆JSON解析。第三性能足够好一个俱乐部几百场比赛记录Hive读起来没有任何压力。5.2 定义比赛记录模型并生成adapterHive跟json_serializable那种代码生成玩法类似需要定义一个带注解的模型类然后运行命令生成adapter。import package:hive/hive.dart; HiveType(typeId: 0) class MatchRecord extends HiveObject { HiveField(0) String player1Name; HiveField(1) String player2Name; HiveField(2) int player1Score; HiveField(3) int player2Score; HiveField(4) int player1Games; HiveField(5) int player2Games; HiveField(6) int player1FinalScore; HiveField(7) int player2FinalScore; HiveField(8) DateTime createdAt; }运行命令就两个flutter pub run build_runner build --delete-conflicting-outputs执行后会自动生成match_record.g.dart里面有MatchRecordAdapter类。初始化时需要注册这个adaptervoid main() async { WidgetsFlutterBinding.ensureInitialized(); final dir await getApplicationDocumentsDirectory(); Hive.init(dir.path); Hive.registerAdapter(MatchRecordAdapter()); await Hive.openBoxMatchRecord(match_records); runApp(...); }这里有一个鸿蒙上的细节getApplicationDocumentsDirectory在鸿蒙上也需要对应的path_provider实现如果path_provider的鸿蒙适配版本没装Hive.init会拿不到有效路径。我当时的做法是直接给Hive.init传一个从鸿蒙壳工程native侧拿到的文件路径绕开了插件依赖。具体路径可以通过ohos的context.getFilesDir()拿再以参数方式传进Dart侧。这种做法不算优雅但极其稳。5.3 写入与读取的完整链路保存比赛记录的时机放在整场比赛结束、用户点了保存记录按钮。此时从ScoreModel拿终极比分生成MatchRecord塞进boxvoid saveMatchRecord(ScoreModel model) { final record MatchRecord() ..player1Name model.player1Name ..player2Name model.player2Name ..player1Score model.player1Score ..player2Score model.player2Score ..player1Games model.player1Games ..player2Games model.player2Games ..createdAt DateTime.now(); final box Hive.boxMatchRecord(match_records); box.add(record); }读取历史记录是打开一个历史列表页用ValueListenableBuilder监听box的变化有新记录写入时列表自动刷新ValueListenableBuilderBoxMatchRecord( valueListenable: Hive.boxMatchRecord(match_records).listenable(), builder: (context, box, _) { final records box.values.toList().reversed.toList(); return ListView.builder( itemCount: records.length, itemBuilder: (context, index) { final record records[index]; return ListTile( title: Text(${record.player1Name} vs ${record.player2Name}), subtitle: Text(${record.player1Games} : ${record.player2Games}), ); }, ); }, )这里有个经验是Hive的box操作要确保在App启动时就open千万不要在页面里第一次调用时懒加载初始化。懒加载看着省事但首次读取会有一次微小的白屏延迟在历史页面这种场景体验不好。5.4 历史记录里值得展示的信息以及一个顺手加的导出功能历史列表我除了展示对阵双方和总比分还把单局比分用21:18 21:12 19:21这种字符串串起来存进去。这些数据对赛后复盘非常有用可以快速看出一个选手是全盛碾压还是险胜翻盘。当前数据库模型里可以用一个String字段存局分明细读取后按空格拆分即可展示。顺手做了个文本导出功能把比赛记录格式化成纯文本走系统分享发送给俱乐部管理员。这个功能虽然简单但实际使用中反馈特别好——他们每场比赛打完直接把文本发到群里不用再手动输入数据。这算是计分器从工具原型到真正被用户依赖的关键一步。6. 鸿蒙真机调试与打包发布踩过的坑都在这6.1 hdc连接设备与日志调试鸿蒙真机调试的第一步是把手机通过USB连到电脑然后在命令行验证连接状态。hdc是鸿蒙设备连接的官方工具类似Android的adb。如果电脑之前装过Android开发工具注意hdc和adb的端口默认值可能冲突必要时关掉adb进程腾出端口。hdc list targets hdc install entry-default-signed.hap hdc hilog | grep flutterhdc install失败是最常见的开局问题。失败信息如果提示签名不对九成是hap没有正确签名如果提示设备离线重启hdc服务通常会好hdc kill然后hdc start。日志查看用hdc hilog过滤flutter关键字Dart侧的print输出会显示在flutter标签下和引擎native日志区分明显。我的实际工作流是DevEco构建出haphdc install装上然后纯靠hdc hilog看运行时异常。这套流程虽然比不上一键热重载舒服但对计分器这种小项目来说完全够用改代码后重新构建装包的周期也不长。6.2 Gradle插件报错的处理一个让人头大的版本迁移编译鸿蒙壳工程时最大的坑来自Gradle插件声明方式。我遇到一个报错信息大概是You are applying Flutters main Gradle plugin imperatively using the apply script method, which is no longer supported by this version of Flutter and will result in build failures。直白说就是工程里还在用老的apply from: flutter.gradle方式声明Flutter Gradle插件而当前工具链要求改成标准插件DSL。这个问题的根因是项目模板生成时沿用了旧配置DevEco的Gradle插件版本和Flutter插件版本都在一直升级两边一碰撞就崩了。解决办法不复杂把工程里settings.gradle和模块build.gradle的插件声明方式切成新写法// settings.gradle plugins { id com.android.application version 7.3.1 apply false id org.jetbrains.kotlin.android version 1.8.0 apply false } // 模块build.gradle plugins { id com.android.application id org.jetbrains.kotlin.android }删掉旧式的apply from:语句之后再重新sync问题就消失了。这个坑的特点是报错信息在Flutter社区里能找到但很多人习惯把传统Android工程的修复经验直接套过来结果越改越乱。我的建议很简单如果报错涉及插件声明方式就直接照新模板逐句比对不要尝试打补丁。另一个连带问题是JDK和Gradle版本匹配。DevEco自带的JBR版本和Gradle版本如果和你本地装的不一致sync过程会直接失败或者报各种奇怪的兼容问题。最省心的做法是用DevEco自带的配置打开工程不要手动去改Gradle JVM参数。6.3 签名与HAP产物没有签名的hap是装不上真机的在DevEco里构建hap时如果只构建了默认的unsigned包hdc install会报错安装失败。签名配置有两个姿势一是DevEco的自动签名登录华为开发者账号后让工具自动申请调试证书二是手动配置p12和profile文件。对个人开发者和内部测试场景手机登录开发者模式用DevEco自动签名一步到位坑最少。签名完成后构建产物一般在这个路径ohos/entry/build/default/outputs/default/entry-default-signed.hap这个hap可以直接分发给同型号鸿蒙设备安装但要注意自动签名的证书只适用于调试阶段上架应用市场或者大规模分发还需要换成正式证书。我在这个项目里目前只用了自动签名做俱乐部内部测试正式发布时会走一次华为应用市场的证书申请流程。6.4 渲染引擎与包体积两种模式都要有个心理预期Flutter新版最出名的变化是渲染引擎从Skia切到了Impeller。Impeller在iOS和Android上解决了早期Skia的掉帧问题但在鸿蒙适配版本里并不一定默认启用。如果真机上出现奇怪的渲染闪屏或中文字体发虚可以尝试在初始化时启用软件渲染兜底// 在一些骁龙/麒麟GPU驱动兼容性不理想的情况下 // 可以考虑在原生壳工程的Flutter初始化参数里加 // --enable-software-rendering不过软件渲染对计分器这种静态UI完全没影响如果只是为了稳定性考虑用起来没有心理负担。包体积是另一个现实问题。Debug模式的hap因为包含了引擎调试符号和Dart JIT内核体积会大得很夸张我这边Debug包一度接近1GB。打Release包会显著缩小但Flutter引擎的so和assets加在一起最终hap依然比纯ArkTS应用大一个量级。俱乐部的场景不在乎这点体积但如果你要把APP给用户下载就必须在发布前用Release模式构建并检查体积。另外Flutter在鸿蒙上构建出来的类似Android AAR的产物叫HARHarmony Archive这是鸿蒙侧的组件包格式。如果你后续想把计分器核心逻辑做成可复用的模块或者纳入自己的组件库走HAR打包形式会顺手很多。Flutter引擎本身在鸿蒙侧以动态库形式集成这部分一般不需要业务开发者关心但要清楚它和普通ArkTS库的依赖关系。6.5 整理一下整体感受项目是在一个周末的完整开发流程里跑通的从环境搭建到真机导出第一个计分器版本实际遇到的坑比预想中少。回过头看Flutter跑鸿蒙真正的瓶颈不在引擎不在框架而在每个插件的鸿蒙适配度。计分器这个项目非常幸运核心功能只用到了Provider和Hive这种纯Dart方案跨原生通道的部分极少因此没有在插件适配的泥潭里挣扎太久。如果要给后来人一个优先级建议我的想法是先把核心业务用纯Dart打通原生插件依赖越少越好等核心链路稳定后再逐个补齐音效、通知、分享这些增强功能。这条路对中小型工具类APP来说完全是一条可以走通的捷径。实际用起来计分器在羽毛球俱乐部的比赛现场连续跑了一整天没重启过一次比分存储、撤销、换局这些核心功能都非常稳。至少对我来说这个项目已经证明Flutter跨平台鸿蒙开发不是只能跑Demo而是能真正交给用户使用的产品路径。
返回列表