如果你以为校园勤工俭学App就是把岗位列表和报名按钮拼在一起,那项目大概率会在第三周开始返工。我今年接手这个某高校勤工俭学平台改版时,需求列表已经从“能浏览岗位、能报名”一路膨胀到用工单位审批、面试排期、工时上报、月度结算,而且设备侧要求跑在 OpenHarmony 上。我们最终用 Flutter 作为跨端框架,把业务模型重新梳理了一遍,再逐层映射到页面结构。这篇文章就以 Flutter × OpenHarmony 这个组合为主线,分享数据结构与页面构建过程中的完整思路、踩坑记录和性能调优手段,适合正在做跨端应用且需要适配 OpenHarmony 的开发者参考。
1. 为什么选 Flutter × OpenHarmony,而不是一条路走到黑
1.1 需求端:勤工俭学App并不是一个列表页
先还原一下业务的真实压力。学生需要看岗位、筛岗位、投递申请、查录用结果;而用工单位经办人(食堂、图书馆、实验室、行政办公室等角色)需要发岗位、查申请人、约面试、确认工时;管理员还要做最终的结算审批。这三个角色的操作面积摊开之后,单页应用已经撑不住,必须按“领域模型 + 状态机 + 多模块页面”来搭。
我当时列了些候选方案:直接用 OpenHarmony 原生 ArkUI 开发,界面能做但跨端复用为零;用 Flutter,能同时保留后续扩展到其它平台的可能性,但 OpenHarmony 上 Flutter 生态还没到“开箱即用”的程度。最终选 Flutter × OpenHarmony,不是因为它成熟,而是因为业务需要在“学生端”和“管理端”之间保持高度一致的数据语义,跨端复用带来的长期收益远大于适配成本。
1.2 技术端的现实:OpenHarmony 上的 Flutter 不是 Android 平替
一个必须说清楚的事实:OpenHarmony 上的 Flutter 目前主要靠社区维护的专用 SDK 分支跑起来,构建产物是 .hap 包,而不是 Android 的 APK。它通过平台通道和 OpenHarmony 的 Ability、Service 等机制通信,很多你以为“第三方包天然可用”的假设,在 OpenHarmony 上都要重新验证。所以选型阶段就要立规矩:凡是纯 Dart 的包优先,凡是依赖原生插件的包必须提前确认有没有 OpenHarmony 侧实现。
这套规矩不是保守,是为了让“数据结构”和“页面构建”在开发中不被底层适配问题反复打断。业务模型先稳定,页面才谈得上性能与体验。
2. 数据模型:岗位、申请、工时与结算的拆解思路
2.1 多角色的核心实体:岗位与用户信息
勤工俭学 App 的第一对核心实体是“岗位”和“用户画像”。岗位实体不能只有“标题、薪资、人数”这种静态字段,它必须承载审批状态、招聘进度、工种标签、时段要求等信息。
class JobPost { final String id; final String title; final String category; // 餐饮/图书/行政/实验室/宿舍协助 final double hourlySalary; // 时薪,单位元 final String departmentId; // 用工单位 final String locationDesc; // 地点描述 final int capacity; // 需求人数 final int appliedCount; // 已申请人数 final bool needInterview; // 是否需要面试 final JobStatus status; // 草稿/发布中/已满/已结束 final DateTime publishTime; }用户画像则要区分身份模式。学生端和用工单位端虽然处于同一套数据库,但读写的字段截然不同。学生画像包含学号、专业、可值班时间段、技能标签;用工单位画像则包含部门名称、审批人、结算周期。这里的关键是在数据层就做角色隔离,而不是在页面层写一堆 if。
2.2 申请与录用状态机
勤工俭学的“报名”绝不是一条数据插进去就结束。它天然是一个状态机:草稿 → 已提交 → 面试待定 → 已录用/已婉拒 → 已上岗 → 已结束。其中“已撤回”“岗位已招满后自动关闭申请通道”也是业务里真实存在的状态。
我用一张表把流转关系固定下来,所有代码里的状态切换都以这张表为准:
| 状态 | 触发角色 | 可流转状态 |
|---|---|---|
| 草稿 | 学生 | 已提交、已撤回 |
| 已提交 | 用工单位 | 面试待定、已婉拒、已撤回 |
| 面试待定 | 用工单位 | 已录用、已婉拒 |
| 已录用 | 系统/用工单位 | 已上岗、已结束 |
| 已上岗 | 学生/用工单位 | 已结束 |
状态机建好后,“申请记录”的数据结构就非常清晰了。每条记录带 jobId、studentId、status、applyTime、interviewTime、feedback、lastUpdatedBy,接口层只暴露有限的状态流转方法,不允许页面直接改状态字段。
2.3 工时记录与月度结算的金额计算
工时上报是最容易出“脏数据”的地方。学生打卡进入、打卡离开,系统用时间差算出工时,但真实世界存在接班延迟、午休扣减、超时不计入等规则。我们的做法是:
- 原始打卡记录不可直接修改,只允许追加修正记录。
- 可计工时按半小时粒度向下取整,避免 10:01 签到、12:00 签退被算成完整的 2 小时。
- 结算单按月独立成表,记录当月累计工时、时薪单价、应发金额和状态(待提交/审批中/已发放)。
这种拆法把“记录事实”和“计算结论”分开。打卡记录是事实,工时的修正记录也是事实,而结算单是一份可审计的结论。页面上的“本月收入”只是这些结论的投影。
3. 页面结构:从数据流到 View 层的骨架设计
3.1 底部导航与四大模块划分
数据模型稳定之后,页面骨架就自然浮现了。学生端底部导航设为四个 Tab:岗位大厅、我的申请、工时记录、个人中心。用工单位端则是岗位管理、申请审批、工时审核、单位设置。这样划分的原因是每个 Tab 都对应一组独立的状态集合:
- 岗位大厅:岗位列表筛选、详情展示、报名入口。
- 我的申请:申请状态时间线、面试通知、撤销操作。
- 工时记录:月度打卡日历、工时明细、结算单入口。
- 个人中心:身份信息、可工作时间偏好、账号配置。
用 IndexedStack 保存四个 Tab 的页面状态,避免切换时频繁重建列表,这是 Flutter 页面构建里成本最低但收益最明显的处理。
3.2 岗位大厅的列表与筛选交互
岗位大厅是整个 App 流量最大的页面,设计成“横向分类 Tab + 列表流”。顶部横向 Tab 是工种分类,点击后切换对应列表,右侧提供按地点、时段、薪资排序的底部筛选面板。
列表项采用固定高度的卡片设计,卡面上展示岗位标题、部门标签、时薪、剩余名额、是否需要面试。固定高度意味着可以用itemExtent直接指定滚动元素高度,让 ListView 在每次滚动时省去反复重新计算布局的开销。卡片内容不塞太满,详情页负责讲完所有故事。
3.3 状态管理选型与数据到页面的流转方式
状态管理我选了 Provider + ChangeNotifier。没有上 Riverpod,是因为这个项目状态种类有限,团队共识成本越低越好。整体数据流是一个典型的单向流:Repository 层负责从接口或缓存拿数据,Controller 层把数据转换为页面状态,页面通过watch感知变化并重建局部 UI。
class JobListController extends ChangeNotifier { List<JobPost> _jobs = []; JobFilter _filter = JobFilter.empty(); Future<void> load(JobFilter filter) async { _filter = filter; _jobs = await jobRepository.fetchJobs(filter); notifyListeners(); } List<JobPost> get filteredJobs => _jobs; }这个控制器只做数据和状态的转换,不碰任何 Widget。页面在build方法里使用context.watch<JobListController>(),新增筛选条件时调用load方法,整个链路清爽且可测试。
4. OpenHarmony 构建流程:从SDK准备到首个HAP落地
4.1 环境准备与项目创建
要在 OpenHarmony 上跑 Flutter,第一步是准备带 ohos 支持的专用 Flutter SDK 分支和官方配套 IDE。版本之间要注意匹配,我当时因为 SDK 分支与 IDE 版本不一致,编译报错浪费了整整一天。建议按这套步骤操作:
- 安装官方 IDE 并配置 OpenHarmony SDK 路径。
- 拉取社区维护的 Flutter for OpenHarmony SDK 分支,替换本机默认 Flutter SDK。
- 执行
flutter doctor,确认 ohos 工具链被识别。 - 创建项目后用
flutter create --platforms=ohos .生成 ohos 平台目录。 - 在 IDE 中打开 ohos 目录,同步工程后编译 HAP。
4.2 第一个编译细节:插件注册与目录结构
项目创建后,目录里会出现ohos/文件夹,结构类似 Android 工程,但应用描述文件是module.json5。这个文件与 Flutter 侧的插件注册息息相关。Android 上插件自动注册到GeneratedPluginRegistrant,OpenHarmony 侧则需要确保每个有原生代码的 Flutter 插件都被桥接到 Ability 的加载逻辑中。
第一个容易踩的坑是:第三方插件如果只有 android/ios 实现,没有 ohos 目录,那么编译不会报错,但运行时所有调用都会静默失败或抛出“not found”。所以每次引入依赖前,先去看插件的包目录里有没有 ohos 或对应适配实现,没有就直接换方案。
5. 适配期的三个真实排错记录
5.1 目录拿不到导致本地缓存崩溃的完整排查链路
现象是:应用在 OpenHarmony 真机安装后,进入岗位列表页白屏,日志里出现目录拼接空字符串的异常。我的第一反应是本地缓存目录初始化失败。
排查链路:
- 在初始化代码里打印
getApplicationDocumentsDirectory()的返回值,结果是null。 - 怀疑 path_provider 插件版本与 Flutter SDK 分支不匹配,升级到最新后问题依旧。
- 翻看 ohos 目录下的插件加载代码,发现 path_provider 的原生实现根本没有被注册进工程。
- 临时方案是在 Dart 侧通过平台通道调用 OpenHarmony 的上下文接口,拿到 filesDir 后交给缓存层。
- 最终把我们自己封装了一个目录服务接口,内部优先走插件方案,失败时走通道方案,保证缓存层永远拿得到路径。
这件事给我的教训是:OpenHarmony 上不能默认“插件存在即有实现”。任何平台依赖都要加一层可替代的接口。
5.2 网络权限声明不一致带来的“真机连不上接口”
页面能打开了,但岗位列表的网络请求在真机上始终失败,而在模拟器上一切正常。这个问题的隐蔽性在于代码没有报错,只是请求超时。
排查链路:
- 检查 Flutter 侧 dio 请求的 baseUrl 和证书配置,没有问题。
- 真机上用浏览器打开同一个接口地址,发现网络本身是通的。
- 对比 Android 工程和 ohos 工程,发现 Android 的权限声明写在 AndroidManifest.xml,而 OpenHarmony 应用需要在 module.json5 中通过 requestPermissions 字段声明网络访问权限。
- 补上网络权限声明后请求恢复正常。
这个坑的原因在于我们初期沿用 Android 的权限配置习惯,忽略了 OpenHarmony 的权限模型差异。定位权限、存储权限同理,都必须在 module.json5 中按 OpenHarmony 的规则声明,而且部分受限权限还要求运行时弹窗授权,不能全指望清单声明一次搞定。
5.3 图片缓存插件“not found”的隔离方案
有一回首页岗位封面图全部显示为空白,日志显示 cached_network_image 依赖的底层插件没有 OpenHarmony 实现,直接抛了“plugin not found”。排查过程比较直接:翻插件源码,发现它依赖原生侧的缓存目录和网络栈,而这两个能力在 ohos 目录下都没有实现,整个插件都无法工作。
我们没有去给第三方插件补原生代码,而是把图片加载抽成了一个ImageRepository接口:
abstract class ImageRepository { Future<String> getImagePath(String url); } class DioImageRepository implements ImageRepository { final _cachePrefix = 'job_covers'; // 先用 dio 下载到 filesDir/cache 下,按 URL hash 命名, // 命中缓存直接返回本地路径,未命中则下载后写入。 }列表页的图片组件只认ImageRepository,不直接 import 任何第三方图片缓存包。这样既绕开了适配问题,也保留了后续切换更成熟方案的能力。顺带还能控制图片 TTL,48 小时后自动清理旧缓存。
6. 弱网下的岗位列表:缓存策略与首帧优化
6.1 列表数据的冷热分离
学生在食堂打开 App 的场景非常常见,信号差、带宽低,不能每次都让用户盯着转圈图标。我们把岗位列表数据拆成冷热两层:热数据是最近一次成功请求的完整 JSON 快照,冷数据是按分类、筛选条件切分的增量更新。
进入岗位大厅时,先读热数据里的岗位标题列表渲染首帧,再通过异步请求刷新筛选后的完整数据。这样即使网络完全不可用,用户依然能看到上一轮的岗位信息,App 在观感上不会“死掉”。
6.2 列表项构建与滚动性能
首帧过后,滚动性能决定了用户愿不愿意继续逛下去。除了前面提到的itemExtent固定卡片高度,我们还做了一项容易被忽略的优化:将卡片上的部门标签、地点、薪资等文本信息预先格式化为字符串,避免 build 过程中反复做时间格式化和金额拼接。
网络图片统一走了ImageRepository的本地文件路径之后,列表项不再需要等待网络解码图片。低端设备上实测滚动流畅度明显改善,弱网环境从点击到首帧稳定在几百毫秒级别。
6.3 加载状态的分级展示
网络状态不能只用“加载中/加载失败”两种。我们区分了四档:本地缓存可立即展示;后台刷新中;刷新失败但缓存可用;彻底无数据。前三档在页面上都不打断用户操作,只有彻底无数据时才展示空白页和重试按钮。
这套分级展示的核心价值在于让页面不依赖网络,数据层先把“有的东西”给出去,页面永远有内容可画。用户感知不到底层的失败重试,只觉得自己用的应用很稳。
项目推进到后期,我的体会是:数据结构和页面构建的关系,就像地基和墙体,数据结构有一处偷懒,页面层迟早要加倍偿还。Flutter × OpenHarmony 的适配工作确实有额外成本,但把它当成架构的一部分来设计,而不是收尾时打补丁,反而能逼着整个项目更早思考平台差异、插件边界和缓存策略。如果你也在做类似场景的跨端应用,建议从一开始就建立“克制依赖、接口隔离”的意识,把最不确定的第三方能力全部封装在抽象层后面。这个习惯,比多写几个页面更值钱。