Flutter做鸿蒙开发这事儿,我盯了挺久。前两年说Flutter要支持鸿蒙还是小范围讨论,现在官方都直接合入主分支了,生态也慢慢长起来。正好赶上项目需要,我就拿了个真实需求练手——做一个高校课表APP,目标很明确:一套代码,Android、iOS、鸿蒙全跑通,省掉三套团队三套代码的麻烦事。
这篇文章就把我从搭工程到最终上架的全过程拆开讲,不走学院派路线,全是踩过坑之后的经验,一部分偏鸿蒙适配的内容我也会讲得细一点,毕竟现阶段这块的资料相对零散,很多细节不实测根本不知道。
1. 项目概述与整体设计思路
1.1 核心需求解析与功能范围
课表APP听起来简单,真做起来涉及的功能点不少。我梳理了一下核心需求,大概分这么几块:
课表展示是基本功。周视图、单日视图都得有,课程按节次排列,每节课要有课程名、教师、教室、周次范围这些基础信息。周次这个东西很坑,高校学期一般20周左右,每门课不是每周都上,单双周、前八周、后八周,各种上法都有,数据模型设计不好,后面解析课程数据的时候会非常痛苦。
课程管理是刚需。手动添加课程、从教务系统导入课表。手动添加好说,做个表单就行。导入这块是重头戏,各个高校教务系统网页结构五花八门,一开始没打算做通用解析器,只针对常见格式做了适配。
提醒功能是我自己加的需求。我大学那会儿经常忘课,尤其那种一周就一节的专业选修课,做APP的时候直接把上课提醒加了进去,支持课前10分钟、30分钟自定义。消息推送跨平台差异极大,这块放后面细说。
设置界面和主题切换,这东西技术上不难,但很体现完成度。APPP做完自己要用,界面丑了是真没动力打开。我当时把亮色暗色主题都做了,用的Flutter内置的ThemeData,成本很低收益却很大。
需求里最有挑战的其实不是UI也不是数据模型,而是“一套代码三端跑通”这个Flag。鸿蒙端Flutter运行效果如何,很多细节网上根本没讲清楚,只能拿实机一步步试。
1.2 技术选型:为什么是Flutter而非原生或其他跨平台方案
技术选型阶段我确实纠结过。方案其实不少,纯原生开发质量最稳但成本最高,安卓一套、iOS一套、鸿蒙未来可能还要一套,小团队玩不转。React Native跨端性不错,但鸿蒙的适配进度一直不太明朗。uni-app在国内用的人多,也宣布支持鸿蒙,但性能上限让我不太踏实。
我最后选了Flutter,主要看中了三点:
自绘引擎解决了最核心的跨平台一致性问题。Flutter不走系统原生控件,而是用Skia引擎自己画UI。这个设计带来了一个很夸张的结果:安卓端和鸿蒙端的渲染逻辑可以完全一致,字体渲染、控件样式几乎不会有平台差异。做课表这种格子多、层次感要求高的UI,这个优势太明显了。
Dart语言的AOT编译在性能上很能打。课表列表滑动这种场景,帧率要求不高,但动画切换、课程卡片展开收起这种交互,如果UI线程卡顿会特别明显。Flutter的编译策略是发布版走AOT,直接编译成机器码,性能和原生App的差距可以控制在可接受范围内。
第三点才是决定性的:Flutter官方把鸿蒙端合并进了主分支,这意味着适配工作是社区持续在做的,不是某个厂商绑定的方案。三端代码逻辑可以做到完全复用,只有极少数系统能力调用需要写Platform Channel。
有一说一,鸿蒙端Flutter的性能表现和安卓还是有细微差距,主要在首帧渲染速度和部分动画流畅度上。但考虑开发成本,省下的工作量不是一点半点,这个取舍是划算的。
1.3 架构规划:一套代码三端复用的分层设计
跨平台APP最怕的就是写着写着平台耦合太深,后面想加平台支持搞得想把代码重写一遍。我提前定了分层规范,把整个应用拆成四层。
数据层在最底下,只管数据存取。课程数据的持久化用的是sqflite,轻量级SQLite封装,Dart侧调用,三端API完全一致。网络请求统一走dio,后面做教务系统课表解析的时候也用得上。
业务逻辑层跑在中间,负责课程数据的解析、周次计算、时间表转换这些核心算法。这一层有个铁律:不允许出现任何平台相关的API调用。我在code review的时候发现过几次有人顺手在业务层写了Platform.isAndroid这种判断,全部打回去重写。跨平台项目的生命线就在这层,保持纯粹性,后面加新平台的支持几乎零成本。
UI层用Flutter的标准Widget树搭建,纯Dart代码。状态管理我用的Provider+Riverpod的组合,版本上选了Riverpod 2.x,因为它在异步状态处理上比Provider顺手得多。UI层要做的就是把业务层的数据喂给各个Widget,自己不写逻辑。
平台通道是第四层,专门处理系统级能力。鸿蒙端和安卓端的推送token获取、应用版本号、系统语言这些,都通过MethodChannel调原生代码实现。这部分的代码量很少,但每一条都要仔细测。
分层的好处是调试时定位问题极快。出Bug了先判断是哪一层的问题,数据层的查SQL,业务层的打日志看中间结果,UI层的开Widget Inspector,根本不需要上断点一步步追。
2. Flutter鸿蒙开发环境配置与工程搭建
2.1 开发工具与SDK版本选型
搭建环境是我在这项目里第一次踩坑。网上一堆教程写得很随意。先说结论,我最后用的是Dart 3.2.x搭配Flutter 3.16.x的版本组合,鸿蒙SDK用的API 9及以上版本,开发工具选了DevEco Studio配合FVM做Flutter版本管理。
版本选型这块我吃过亏。刚开始我用的老版本Flutter,配套的鸿蒙适配Flutter SDK拉到项目里一堆编译报错。后来才知道,鸿蒙适配的工作一直跟着Flutter主分支合入节奏走,版本落后的话,Dart API和鸿蒙侧的运行时库根本对不上。
环境搭建的核心步骤我整理成清单,照着走基本不会出问题:
安装DevEco Studio,配置好鸿蒙SDK路径。这一步没啥技术含量,但一定要在系统环境变量里把鸿蒙SDK的路径配好,后面Flutter工程关联鸿蒙项目的时候要用。
安装Flutter SDK时建议用Git直接拉稳定分支,然后跑flutter doctor检查依赖。这个命令会把残缺的组件都列出来,缺啥补啥就好。
用fvm管理Flutter版本。fvm和Node的nvm一个思路,安装指定版本然后项目内锁定。我的pubspec.yaml里还配了flutter_harmony的依赖分支,这个是鸿蒙适配的关键包,编译的时候会自动拉鸿蒙侧的引擎实现。
创建鸿蒙工程模版,在DevEco Studio里新建一个Empty Ability工程,注意包名要和Flutter工程保持一致。这个比后人容易忽略,两侧包名对不上会导致鸿蒙端启动Flutter页面时直接白屏。
配置关联。在鸿蒙工程目录下执行命令把Flutter模块和鸿蒙工程关联起来,然后同步Gradle依赖。这一步跑完,项目结构里会出现一个持有Flutter模块的鸿蒙壳工程。
我建议所有开发者在动手写代码之前先跑通一个Hello World级别的Demo,把三端跑起来确认没问题再开始写业务。我见过太多人一上来直接拉课表工程,结果编译不过去还以为是自己的代码问题,排查了两天才发现是环境没配对。
2.2 鸿蒙工程与Flutter工程的目录结构
很多人第一次接触鸿蒙项目的工程结构会有点懵。鸿蒙开发用的是ArkTS语言,和Flutter完全是两套体系,但工程结构上有一些类似的概念可以对应起来。
鸿蒙壳工程的模块是一个ArkTS写的Ability,它相当于安卓的Activity,是应用入口。我在这个壳工程里做的事情很少:创建一个Ability,然后在它的窗口加载Flutter的渲染结果。ArkTS侧负责管理生命周期,比如应用切后台、断网重连这些系统事件,通知Flutter侧做对应处理。
Flutter模块放在壳工程里,有自己的pubspec.yaml、lib目录。Dart代码在这个模块里写,跑的在鸿蒙上是个独立的运行时环境。我一度以为鸿蒙端Flutter是在WebView里跑的,后来看了鸿蒙侧的适配引擎才知道,它是直接通过自绘组件上屏渲染的,性能和安卓的Flutter是同一套机制。
目录结构上有个关键点:鸿蒙端Flutter应用的原生代码不是Java也不是Kotlin,而是ArkTS。这意味着Platform Channel的原生侧实现得用ArkTS来写,网上资料里用Java写原生代码的例子完全不能照抄。我为了拿系统推送token,先看的安卓example代码,然后在ArkTS里重新实现了一遍,API风格差异很大。
2.3三端工程联调与多窗口调试技巧
三端联调大概是这个项目里最需要耐心的事。建议调试顺序是:先安卓模拟器,再iOS模拟器(如果条件允许),最后鸿蒙真机。安卓和iOS跑通了,说明业务层和UI层没问题,剩下鸿蒙端的排查范围就缩到平台通道和壳工程部分,定位快得多。
调试鸿蒙端Flutter,我用的工具是DevEco Studio自带的调试器,能直接对ArkTS代码下断点。Flutter侧的调试走VSCode的Dart插件模式。两边要同时调试,我开了两个IDE窗口,遇到跨端问题就两边断点结合着看。
打日志也有讲究。Dart侧print只输出到Flutter控制台,原生侧的日志在DevEco的HiLog里,两边日志混在一起非常乱。我在关键入口都做了带前缀的标准日志,比如DART-UI、DART-NET、ARKTS-PUSH这种打头的Tag,排查问题时用关键字过滤,效率提升很明显。
网络请求调试我用了抓包工具,安卓和鸿蒙都走同一个出口,抓包地址直接填Flutter侧的代理IP。这步没遇到什么波折。唯一不习惯的是鸿蒙真机的调试授权流程比安卓繁琐,每次重新插拔设备都要重新授权,坑倒是不坑,就是效率低。
3. 课表数据模型设计与核心算法实现
3.1 从教务系统网页解析课表数据的思路
课表数据的获取是这个项目里最让我头疼的部分。手动添加课程当然能跑通,但用户根本不想一个一个输入。我做了一个可视化导入工具:用户从学校教务系统网页复制课表文本,粘贴进来,应用自动解析成结构化数据。
教务系统的网页结构五花八门,但课表基本逃不出两种形式:表格型和全文描述型。表格型最简单,思路就是定位到课程格子的行列索引,拆出周几和第几节这两个关键坐标,然后在格子内容里正则匹配出课程名、教室、教师、周次信息。这个模式我用正则匹配就解决了八成问题,实测对多数高校都适用。
全文描述型稍复杂,但信息更多。比如“高等数学|张三|教三-201|1-16周|周一第1-2节”这种格式,直接用正则做文本解析就行。教务系统导出的课表文本格式相对固定,我写了几套正则模板来适配不同的排版风格。
正则适配的过程特别磨人。我对着一个学校的六个不同教务系统样本调了一下午正则,从开始怎么匹配都对不上,到后来发现教务系统导出的课表文本里混着全角半角空格、没有规律的换行、缩进诡异的制表符,各种脏数据。我真建议做这块的开发者写解析逻辑时,先做文本归一化:全角转半角、压缩连续空格、统一换行符,这步做完解析成功率翻倍。
3.2 周次与节次的数据模型设计
上过大学的人都懂,课程时间不只是“周几第几节”那么简单。我总结下来至少有三个维度要建模:周次范围、单双周限制、节次组合。
周次范围我用一个列表来表示,比如[1,2,3,4],或者简化成一个格式化的Range对象,两头是起止周。单双周用isOdd/isEven两个布尔标记来表示,有的课程还需要用周次列表直接精确到周,比如“只在3、5、7、9周上课”。
节次是整个模型里最微妙的字段。大多数课是连着两节甚至三节的,比如“周一第1-2节”,建模时用节次数组[start, end]表示,直观又灵活。为了处理跨节的课程,我在数据模型层用一个TimeInterval结构,把星期几、起始节次、结束节次串起来。
我设计了一个单独的时间配置表,而不是把节次和具体时间写死在各门课的数据里。因为不同高校的上课时间不一样,有的学校上午五节下午四节,有的上午四节下午五节,节次和时间的映射关系我做成可配置项,未来适配其他学校时只需要换一套配置,不用动数据模型。
3.3 课程表渲染引擎的设计细节
渲染是课表APP最核心的交互体验。网格布局我用Flutter自带的GridView做基础,但课表的交互复杂度比普通网格高不少,我最终还是用自定义的Stack+Ruler方案,因为课程卡片的宽高比例、周数和节次对齐的精度、多周滚动时动画帧率,GridView很难满足全部需求。
我的方案是这样的:用CustomMultiChildLayout构建课表网格的骨架,时间轴在左侧,课节行从上到下排列。周几的列头放在顶部,用FixedHeader做到纵向滚动的吸顶效果。课程卡片用Positioned组件绝对定位到对应的格子里,宽度和高度根据课程的节次区间动态计算。
拖拽和点击的冲突处理是这个渲染引擎设计中最容易翻车的地方。用户单击课程卡片要弹出详情弹窗,但同时卡片支持长按拖拽换时间。手势冲突处理不好就会一个操作两个响应。我用GestureDetector的手势竞技场机制,设了长按优先,只有超过500毫秒的长按才触发拖拽,单击正常触发点击事件。这个细节不处理,用户在课表上拖拽另一门课时会疯狂误触已选中的课程。
悬空的课程位置变化时,我用AnimatedPositioned做位移动画,过渡效果流畅度不错。但要注意,这种动画在列表刷新时容易触发异常,UI层做数据更新时,我建议一次性同步整个数据源,不要一个课程一个课程地异动,否则动画队列会卡爆。
3.4 自定义周次计算模块
周次计算模块是整个项目里逻辑最绕的部分,但也是最能体现代码功底的地方。开学日期输入后,APP自动算出今天是第几周,然后高亮显示当前周的课表。这个功能看似简单,细节却很多:有的学校开学第一周从周日或周一开始算,有的学校每周三才算是新一周的开始,不同学校周定义差异很大。
我的方案是用日期算法动态算带偏移的周序号。设定学期起始的周一为零周,然后计算当前日期和起始周一的差值,除以7向下取整,加上偏移量。这套逻辑我封装成了WeekCalculator类,所有特殊规则都作为参数传入。测试时我直接模拟了10个学校的不同周规则,跑了一轮,逻辑都通过了。
还有一类设计得很诡异的课程,我在处理时直接放弃了自动解析,比如“1-8周,9-16周分别上不同科目”这种极少数案例。手动添加能解决这类问题,自动解析花费的成本远大于收益,得不偿失。
4. 课程数据管理功能实现
4.1 手动添加课程的完整表单设计
手动添加课程的交互流程我用了一个全屏表单页,分两屏推进。第一屏填基本信息:课程名、教师、教室、课程类型。第二屏填时间信息:周几、节次、周次范围、单双周。配置项多的时候分步填写比一屏搞定体验好得多。
表单的校验逻辑我加了几个铁律。时间冲突是课表应用唯一的硬性校验:同一时间不能添加两门课。不过这个校验的边界条件很多,比如单双周不同的课可以在同一时间共存,这种情况下要单独处理校验规则才能保证不误判。
学校上课节次可能跨大节,比如第2节下课直接上第3节,真正的课间只有5分钟。但同一周次时间不是同一门课的情况下,我默认放行;如果连这种都要拦,手动添加课程的流程就变得没法用了。妥协才是产品设计,不是所有冲突都算非法。
4.2 周视图与日视图的切换实现
课表APP最常用的视图就是一周概览,用户打开App就想看这周课表和今天上什么课。我的实现是默认展示当前周整周课表,顶部有一个星期选择器,底部有一个今日课程卡片区。
日视图则是把选中当天的课程竖排展示,每节课配一个开始时间和结束时间。这个视图下我额外做了时间轴的提醒逻辑——如果当前时间已经超过某节课的开始时间但还没结束,就高亮这节课,提醒用户正在上课。
周日视图的切换我保持了同一个数据源,两个视图组件只是渲染方式不同,切换时数据不用二次加载,纯粹的白屏延迟做到了零。切换动画一加,体感就非常接近原生应用。
4.3 数据持久化的方案选择
数据持久化看似简单,但跨平台应用在这上面踩坑的概率很高。Flutter有个好用的插件叫shared_preferences,底层用的是各平台的UserDefaults或SharedPreferences,但课表数据量大、嵌套深,这种KV结构完全不够用。我直接上了SQLite数据库,用sqflite封装。
数据库表结构我设计了这么几张表:课程表、学期配置表、设置表。课程表存储课表的基本信息和时间配置,学期配置表存学期起止日期和周次偏移,设置表存提醒配置和主题偏好。
sqflite在鸿蒙端的性能实测比安卓略慢,但课表这种低频读写的场景差别可以忽略。唯一要注意的是数据库文件路径在不同平台的差异。我封装了一个数据库单例,初始化时统一从指定的数据库路径加载,适配逻辑写在工具类里,业务层感知不到。
4.4 课程导入功能的兼容性适配
课程导入功能是这个项目里投入产出比最高的模块。很多用户根本不想自己录入,把教务系统的课表文本粘贴进来就能解析,这个功能的魔法感很强,很多学生把App安利给室友就是因为这个。
兼容性适配的思路很简单:样本驱动开发。我建了几个模拟教务系统的课表文件来跑解析测试,上线前一共测试了6个不同风格的课表格式,从标准表格到自定义文本,从