把吃灰很久的一块 OpenHarmony 开发板搬到桌上,插上电,打开 DevEco Studio 和终端的那一刻,我就知道这次不是玩票:我准备在上面跑一个看书记录 App。这个 App 的核心功能听上去很简单——把我手头同时在读的几本书管理起来,记录每次读到第几页。真正动手之后你才会发现,OpenHarmony 上的 Flutter 和 Android/iOS 完全是另一套玩法。光是“书籍列表”这一个页面,就足够把环境适配、数据持久化、列表性能、交互反馈和平台通道这些环节全部串起来。
这篇文章是我完整实现“Flutter for OpenHarmony 看书管理记录 App”中书籍列表模块的实战记录,包含设计取舍、完整代码思路、真机调过的经验与踩进去的坑,适合正在评估 OpenHarmony 跨平台方案的团队,或者已经动手但卡在某些细枝末节的朋友。
1. 项目定位:这个 App 最核心的模块为什么是列表
1.1 看书记录 App 的产品边界
做这个 App 之前得先想清楚一个事:看书记录到底解决什么问题?不是“管理一个图书馆”,而是“让我知道我现在读到哪了,接下来该读什么”。
很多人会习惯性往大了设计:书单、笔记、统计、社交分享……功能越堆越多,最后变成一个大而全却没人用的应用。我给自己划定了一条产品边界,只做四件事:
- 把想读的书放进来,区分“在读 / 想读 / 已完成”三种状态。
- 记录每本书的阅读进度:当前页、总页数、读到哪里。
- 一键更新进度,比如读完一章直接点一下。
- 能快速搜索和筛选,书多了以后不至于翻半天。
在这个边界下,“书籍列表”就成了整个 App 的流量入口和主界面。所有核心数据都通过在列表上的呈现、点击、滑动来展示,所以列表模块做得好不好,基本决定了这个 App 能不能用。
1.2 列表模块在整个 App 里的定位
我一开始也想过,要不要先做详情页、阅读器或者数据统计。后来把一个最小可用版本画了一遍线,发现所有功能都得从列表出发:用户进入 App 看到的是列表,点击一本书进入的是详情,更新进度后回来看的还是列表。列表不仅是导航中心,还是状态的可视化载体。
列表要承载的信息非常具体:书名、作者、封面、当前进度、上次阅读时间、状态标签。这些信息如果陈列得不清楚,用户就要多点两次才能知道“我现在读了多少”,那我这个 App 的核心价值就丢了。所以我把列表模块定义为整个项目最先做、也最需要打薄的一层。
1.3 技术方案的取舍与一句实话
技术选型上,平台上跑的是 OpenHarmony,团队最熟的是 Flutter,所以答案基本已经写在问题里了。
但我还是要泼一盆冷水:如果你是为了省事才在 OpenHarmony 上用 Flutter,那你会失望。OpenHarmony 上的 Flutter 目前不是“开箱即用”的状态,工程要调、插件要挑、很多在 Android 上习惯的写法得换。它的价值在于未来跨平台复用已有代码和团队技能,而不是让你今天少敲几行代码。
这次接手项目前,我给自己定了一条铁律:能用纯 Dart 实现的,尽量不依赖原生插件。因为纯 Dart 代码在 OpenHarmony 上几乎不会出兼容性问题,而一碰到原生能力,就得开始和平台通道、插件适配作斗争。这一条在后面帮我省了无数时间。
2. OpenHarmony 上的 Flutter 工程搭建:环境与适配
2.1 环境准备与分支选择
OpenHarmony 上的 Flutter 通常用社区维护的分支,而不是官方主干。这个分支对接到 OpenHarmony 的 ArkUI 能力和 SDK 接口,配置方式和标准 Flutter 差异不小。
我的建议就一条:不要用最新版 Flutter,选一个明确支持 OpenHarmony 的发行版本,并且 DevEco Studio、OpenHarmony SDK、Flutter 分支三者的版本最好组合着来,不要各自升到最新。原因很实际,OpenHarmony 平台通道的适配进度不如 Android/iOS 那么统一,主干版本的 Flutter 可能修了某个通用 bug,却引入了 OpenHarmony 侧的不兼容。我在项目里就吃过一次亏:默认拉了最新 Flutter,结果构建时提示 ffi 相关的符号找不到,换成稳定分支后立竿见影。
环境准备还有一个容易忽略的细节:OpenHarmony 的 SDK 分为 public 版本和厂商版本,开发板出厂自带的镜像版本不一定和你下载的 DevEco Studio 自带的 SDK 版本一致。最好先确认开发板上的系统版本,再回填对应的 SDK,否则真机装应用时会碰到签名或 API level 不匹配的问题。
2.2 工程初始化与目录结构
初始化一个支持 OpenHarmony 的 Flutter 工程,核心不是用什么命令,而是理解它多出来的那个ohos目录是干什么的。
这个目录相当于 OpenHarmony 侧的工程外壳,里面用 ArkTS/Stage 模型描述应用入口、权限和原生配置。Flutter 的 Dart 代码照常在lib下开发,构建时通过 Flutter 引擎把 UI 渲染到 OpenHarmony 的 Surface 上。
我习惯把工程分区得干净一点:
lib/models/:书籍模型等纯数据定义。lib/repositories/:数据存取接口和本地实现。lib/pages/:书籍列表页、详情页。lib/widgets/:列表卡片、搜索栏、进度条等复用组件。
这样的好处是,后面如果要接入服务端同步,改动只局限在 repositories 里,UI 层完全不用动。这个分层思路在 OpenHarmony 项目里尤其重要,因为平台本身的适配还在快速演进,把业务逻辑和平台能力隔离开,等于给自己留了一条随时切换到其他框架的后路。
2.3 第三方插件的兼容判断
Flutter 生态强大的背后是 pub.dev 上铺天盖地的插件,但 OpenHarmony 跑起来后,第一个矛盾就出现了:绝大多数插件只实现了 Android 和 iOS 的原生代码,没有 OpenHarmony 实现。
判断一个插件能不能用,我的顺序是这样的:
- 看它是否依赖原生能力,还是纯 Dart 实现。
- 看它的原生代码是否真的被触发,还是只在特定平台上走通道。
- 在
pubspec.yaml里加进去,直接构建跑一次,比看任何文档都准。
这个项目用到的shared_preferences有 OpenHarmony 适配,直接用没问题;dio是纯 Dart 实现的网络库,也安全;但那些依赖 iOS/Android 系统 API 的插件,比如指纹识别、传感器、快捷方式,基本就别指望了。遇到这种需求,我会优先考虑用一个 PlatformInterface 接口把功能抽象出来,然后为 OpenHarmony 单独实现平台通道,而不是寄希望于插件作者在可预见的未来补上适配。
3. 书籍列表的数据层实现:模型、仓储与持久化
3.1 Book 模型设计:字段越收敛越好
书籍列表的第一块基石是 Book 模型。模型设计最怕两种倾向:一种是什么都往里塞,另一种是字段名随便起。我最终定义的字段没有超过十个:
enum BookStatus { reading, wish, done, } class Book { const Book({ required this.id, required this.title, required this.author, this.coverPath = '', this.totalPages = 0, this.currentPage = 0, this.status = BookStatus.wish, this.tags = const [], this.lastReadTime, }); final String id; final String title; final String author; final String coverPath; final int totalPages; final int currentPage; final BookStatus status; final List<String> tags; final DateTime? lastReadTime; double get progress => totalPages > 0 ? currentPage / totalPages : 0; Book copyWith({ String? id, String? title, String? author, String? coverPath, int? totalPages, int? currentPage, BookStatus? status, List<String>? tags, DateTime? lastReadTime, }) { return Book( id: id ?? this.id, title: title ?? this.title, author: author ?? this.author, coverPath: coverPath ?? this.coverPath, totalPages: totalPages ?? this.totalPages, currentPage: currentPage ?? this.currentPage, status: status ?? this.status, tags: tags ?? this.tags, lastReadTime: lastReadTime ?? this.lastReadTime, ); } factory Book.fromJson(Map<String, dynamic> json) { return Book( id: json['id'] as String, title: json['title'] as String, author: json['author'] as String, coverPath: json['coverPath'] as String? ?? '', totalPages: json['totalPages'] as int? ?? 0, currentPage: json['currentPage'] as int? ?? 0, status: BookStatus.values.firstWhere( (e) => e.name == json['status'], orElse: () => BookStatus.wish, ), tags: (json['tags'] as List<dynamic>? ?? []).cast<String>(), lastReadTime: json['lastReadTime'] != null ? DateTime.tryParse(json['lastReadTime'] as String) : null, ); } Map<String, dynamic> toJson() { return { 'id': id, 'title': title, 'author': author, 'coverPath': coverPath, 'totalPages': totalPages, 'currentPage': currentPage, 'status': status.name, 'tags': tags, 'lastReadTime': lastReadTime?.toIso8601String(), }; } }注意id字段我保留成为必备字段。很多人在本地小项目里会省略 id,直接用一个字符串书名当主键,这是后面删除和更新数据时最想撞墙的设计。id 的作用不是给数据库看的,是给列表的 key、Diff 算法和跨端同步准备的。养成“第一列永远是稳定唯一标识”的习惯,以后接任何存储后端都不会别扭。
模型里额外定义了progress这个只读 getter,把“读完多少”的计算收敛在一个地方,UI 层永远直接读book.progress,不用自己再除一遍。这种小计算放到模型里,比在 UI 层重复写表达式要安全得多。
3.2 数据存储层:可能拷的需求在前,不要一上来就上重型数据库
数据层的抽象,直接照搬 Android 上我常用的 Repository 模式。定义一个接口,再写内存版和本地版两个实现。
abstract class BookRepository { Future<List<Book>> fetchBooks(); Future<void> upsertBook(Book book); Future<void> deleteBook(String id); } class MemoryBookRepository implements BookRepository { final List<Book> _items = []; // 模拟数据在内存里增删改查 } class LocalBookRepository implements BookRepository { static const _storageKey = 'books_v1'; // 基于 SharedPreferences 的 JSON 持久化实现 }一开始用 MemoryBookRepository 来跑通 UI,不落盘也能开发。等列表逻辑稳定了,再切换到 LocalBookRepository 做真机测试。UI 层只认BookRepository这个抽象,不关心背后的数据到底是放在内存里,还是存在本地。
这样做不费多少代码,却把“数据来源”和“页面展示”硬性解耦了。后面想加一个后台同步,只需要新建一个CloudBookRepository把两个来源合并,页面代码一行都不用改。
3.3 JSON 持久化与序列化细节
本地存储我这一段选的是 SharedPreferences 的 OpenHarmony 适配,原因是几百本书的场景根本不需要 SQLite。SharedPreferences 本质上就是一个小型键值存储,存一个字符串列表完全够用。
实现思路很简单:把每本书jsonEncode成一个字符串,整体存成List<String>,读取时再逐个jsonDecode。
class LocalBookRepository implements BookRepository { static const _storageKey = 'books_v1'; @override Future<List<Book>> fetchBooks() async { final prefs = await SharedPreferences.getInstance(); final rawList = prefs.getStringList(_storageKey) ?? []; return rawList .map((raw) => Book.fromJson(jsonDecode(raw) as Map<String, dynamic>)) .toList(); } @override Future<void> upsertBook(Book book) async { final books = await fetchBooks(); final index = books.indexWhere((b) => b.id == book.id); if (index >= 0) { books[index] = book; } else { books.add(book); } await _save(books); } @override Future<void> deleteBook(String id) async { final books = await fetchBooks(); books.removeWhere((b) => b.id == id); await _save(books); } Future<void> _save(List<Book> books) async { final prefs = await SharedPreferences.getInstance(); await prefs.setStringList( _storageKey, books.map((b) => jsonEncode(b.toJson())).toList(), ); } }这里有一个很关键的细节:封面图千万不要以 Base64 字符串塞进 JSON 里存 SharedPreferences。我一开始图省事,把封面缩略图直接转成 Base64 塞进字段,结果几十本书的数据量直接让每次读写卡顿到肉眼可见。正确的做法是封面图保存为文件路径,正文里只存路径字符串。宁可先不展示封面,也好过把存储层搞到崩。
JSON 序列化方面,为了少引一个依赖,我用手写的toJson/fromJson。当模型字段超过十几个、层级开始变深的时候,再考虑json_serializable做代码生成。对于 Book 这种扁平结构,手写反而更直观,也方便在fromJson里做健壮性兜底,比如枚举解析失败时给一个默认状态。
4. 书籍列表 UI 的实现:一张卡片承载一本书的当下状态
4.1 列表框架选型:ListView 而不是 GridView
书籍列表我第一版直接用的ListView.separated。至于为什么不用更“封面墙”的 GridView,是因为这是一款管理工具,不是书店陈列柜。用户在列表上最频繁的动作是扫一眼书名和进度,然后点进去改页码。网格布局对同一个界面的信息密度提升有限,反而让卡片宽度变小,书名和进度条都挤得没法看。
写列表的基础代码很简单:
ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), itemCount: visibleBooks.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { return BookCard( book: visibleBooks[index], onProgress: () => _updateProgress(visibleBooks[index]), ); }, )注意separatorBuilder里我用的间距是SizedBox(height: 12),这样比卡片自带 margin 更清晰,每个卡片之间的空隙是固定可控的。另一个必须加的地方是physics: const AlwaysScrollableScrollPhysics(),没有这个,当书籍数量特别少的时候,列表可能无法滚动,下拉刷新就会失效。
4.2 书籍卡片的布局:中文长书名的处理经验
卡片布局大概是左侧封面、右侧信息的三段式结构,这是我试过最顺手的一种。
- 左侧:固定宽 72、高 100 左右的封面区域,没有封面就显示一个占位色块。
- 右侧上半部:书名(最多两行)、作者。
- 右侧下半部:一条细进度条 + 百分比,以及“上次阅读时间”。
实现时最容易翻车的是书名长度。中文书名动辄十几个字,如果卡片里的文本不限制宽度,会被直接挤出边界。正确写法是给书名Expanded包裹,然后设置maxLines: 2和overflow: TextOverflow.ellipsis。
class BookCard extends StatelessWidget { const BookCard({ super.key, required this.book, required this.onProgress, }); final Book book; final VoidCallback onProgress; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _BookCover(book: book), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( book.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( book.author, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, color: Colors.grey), ), const SizedBox(height: 8), _ProgressLine(book: book), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( book.lastReadTime == null ? '还没读过' : '上次读:${_formatTime(book.lastReadTime!)}', style: const TextStyle(fontSize: 12, color: Colors.grey), ), TextButton( onPressed: onProgress, child: const Text('记录进度'), ), ], ), ], ), ), ], ), ); } }这里提醒一句:卡片里的“记录进度”按钮不要用GestureDetector套整卡片再自己判断点击区域。我在第一版就踩过这个坑,把InkWell放在整个 Row 外层,结果内层按钮的点击事件被吞掉,反复按没反应。要么把整卡做成一个点击源,内部不再放可点击控件;要么内部按钮就独立使用TextButton/IconButton,不要外层再包一个手势组件。
4.3 状态管理:一个小列表页面到底需不需要框架
现在 Flutter 社区一聊状态管理就抬出 Bloc、Riverpod、GetX,好像不用框架就不专业。但我的观点是:页面就一两层,状态不跨页面共享,setState就是最好的方案。给“看书记录”这种体量的项目强行上 Bloc,只会增加样板代码,还没尝到架构红利,先被模板代码淹没。
我最终选了最朴素的做法:StatefulWidget持有BookRepository和一个List<Book>,每次增删改完直接setState。代码短、逻辑直、新手也能一眼看懂。等哪天出现多个页面需要共享同一个书籍状态,再重构也不迟,因为 Repository 抽象已经垫底了,届时换成ChangeNotifier + Provider都只是顺手的事。
4.4 下拉刷新、加载状态与空状态
列表加载还需要处理三种非正常状态:加载中、加载失败、空列表。如果不处理,用户看到的就是一片白屏或者永远转圈的小菊花。
我用的是一个简单的三态管理:
if (_isLoading) { return const Center(child: CircularProgressIndicator()); } if (_errorMessage != null) { return Center( child: Column( children: [ Text(_errorMessage!), ElevatedButton( onPressed: _loadBooks, child: const Text('重试'), ), ], ), ); } if (visibleBooks.isEmpty) { return const _EmptyBooksView(); }一个小细节:空状态页面不能直接用Center包一句话然后丢在那里,要保证整个区域仍能被RefreshIndicator触发下拉。我把_EmptyBooksView做成了一个ListView,里面塞一个占位容器,并保留AlwaysScrollableScrollPhysics,这样用户从空列表下拉也能触发刷新。不少应用在空状态下没法刷新,就是因为空状态组件不是可滚动组件,这个细节实测里很要命。
RefreshIndicator 本身我用onRefresh里返回一个重新加载数据的 Future,这样下拉效果才能完整播放,否则你看到一个转一半就消失的动画。
5. 搜索、筛选与进度更新的实战闭环
5.1 搜索:现场过滤而不是每次查库
书籍列表的搜索看起来简单,但也有设计选择:是每次输入都去查数据库,还是列表现有数据里做内存过滤。对于个人本地数据,内存过滤是绝对的主流,因为数据量撑死几百本,遍历一遍的时间是微秒级,完全没必要访问存储层。
我写了一个同步过滤函数:
List<Book> _filterByKeyword(List<Book> source, String keyword) { final k = keyword.trim().toLowerCase(); if (k.isEmpty) return source; return source.where((book) { return book.title.toLowerCase().contains(k) || book.author.toLowerCase().contains(k); }).toList(); }TextField的onChanged里直接setState,界面即时刷新。如果你的项目将来有上千本书,再考虑加 300ms 的防抖和Future异步过滤,现在没必要。
5.2 状态筛选:用 Tab 还是用按钮组
我一开始是想用TabBar做“在读 / 想读 / 已完成”的切换,但在 OpenHarmony 上跑起来后,发现 Tab 切换的动画在某些版本下会有卡顿,而且 TabController 的监听和列表刷新绑到一起时,手势滑动很容易触发误切换。
后来我改成了一个更稳的方案:页面顶部放一排FilterChip,点击切换筛选条件,简单直接。虽然少了一点“滑页切换”的炫酷,但换来的是稳定和零学习成本。对于工具型 App,筛选的本质是让用户少点两下,不是让他体验转场动画。
筛选逻辑也收敛成一个函数:
List<Book> _visibleBooks() { if (_filter == BookStatus.all) return _books; return _books.where((b) => b.status == _filter).toList(); }我把“全部”也作为筛选选项之一,这样列表数据的流动路径只有一条:_books→ 关键词过滤 → 状态过滤 → UI。任何新条件加入,只要在这个流水线上加一步即可。
5.3 进度更新的交互设计
进度更新是整个看书记录 App 的核心动作,交互上要尽可能快。我提供了两种更新入口:卡片上的“记录进度”按钮和详情页的页码编辑。列表内我默认点击一次按钮就把currentPage加上一章节的近似页数(比如 30 页),并自动把status切到“在读”。
void _updateProgress(Book book) { final updated = book.copyWith( currentPage: book.currentPage + 30, status: BookStatus.reading, lastReadTime: DateTime.now(), ); setState(() { final index = _books.indexWhere((b) => b.id == book.id); _books[index] = updated; }); _repository.upsertBook(updated); }这里有个顺手做的细节:更新完数据后,我立即用setState刷新 UI,然后再异步调用_repository.upsertBook。先改变内存状态,再持久化,顺序不能反过来。反过来会出现 UI 卡一下、然后才更新进度的情况,实际体感差很多。持久化失败的话,下次启动数据会回滚,但至少用户当前看到了正确状态。
进度以“固定 30 页”为模拟步长,只是这个 demo 的简化逻辑。真正对接阅读器时,应该把步长改成“当前章节结束页 - 当前页”,这个逻辑会放在详情页,不污染列表。
6. OpenHarmony 适配疑难与排查实录
6.1 模拟器与真机的体验差异
OpenHarmony 的模拟器能跑通流程,但到了真机上你才会遇到真正的适配问题。模拟器上字体缺失、手势反馈、性能表现都和真机有差异,尤其是布局拉丝、动画丢帧这类问题,模拟器几乎看不出来。
我强烈建议从一开始就准备一台真机。一些在模拟器上正常的页面在真机上可能出现:
- 中文字体渲染发虚或者缺字。
- 返回键触发的是整个应用退出而不是页面回退。
- 下拉刷新动画掉帧。
- 部分原生对话框无法弹出。
这些现象的共同点是,它们都不是 Dart 层代码问题,而是 Flutter 引擎和 OpenHarmony 系统能力之间的对接差异。遇到这类问题,别急着改 UI,先在另一台设备上复现,确认是环境问题还是代码问题。
6.2 高频问题速查表
我整理了一张我在这个项目里实际遇到的高频问题表,后面做 OpenHarmony 双端适配的朋友可以直接对照:
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 中文书名显示成方块或字体发虚 | 系统默认字体缺失或引擎字体回退异常 | 在主题中显式指定中文字体,或使用自带字体的 Text 样式 |
| 页面一返回就退出 App | Navigation 栈处理不当或返回键拦截缺失 | 在页面里使用 PopScope 控制返回逻辑,判断栈内页面数量 |
| 下拉刷新不触发 | 列表不可滚动或空状态组件不是滚动组件 | 给 ListView 加 AlwaysScrollableScrollPhysics,空状态也包成可滚动区域 |
| 第三方插件编译报错 | 插件未适配 OpenHarmony | 优先换纯 Dart 实现,给插件加 PlatformInterface 抽象,或自行实现平台通道 |
| setState 更新后界面不刷新 | 重复的 key 导致列表复用异常 | 给 BookCard 传入 ValueKey(book.id),强制按 id 刷新 |
| SharedPreferences 读写变慢 | 数据量大或存了 Base64 封面 | 封面改存路径,列表存储只保留轻量字段 |
| 热重载后页面状态丢失 | 修改原生代码或平台通道后引擎重启 | 原生改动必须冷启动,Dart 调整才用热重载 |
这个表是我一边开发一边记录的,不一定能覆盖所有 OpenHarmony 版本,但至少能给卡住的人一个排查方向。尤其是“返回键退出应用”这条,OpenHarmony 的返回键行为和 Android 有细微差别,我在模拟器上一直没触发,直到真机才暴露出来,处理方式是在根页面使用 PopScope 拦截并减少返回事件。
6.3 平台通道与插件适配的坑
Flutter 和 OpenHarmony 原生之间通信也是踩坑重灾区。MethodChannel 在 Android 上注册原生代码的位置通常在 MainActivity 的configureFlutterEngine里;到了 OpenHarmony,要换成 Stage 模型下的Ability生命周期去关联引擎。如果按 Android 的习惯到处找 MainActivity,肯定找不到。
排查通道问题的通用步骤我总结了三步:
- 确认 Flutter 侧 Channel 名称和原生侧完全一致,大小写必须一模一样。
- 确认 Dart 侧调用方法的异步回调有超时处理,不要无限等。
- 把原生侧实现先写一个日志输出,把能接到的调用打出来,再逐步加业务逻辑。
如果你要用 EventChannel 做持续事件流,比如监听阅读进度变化、传感器数据等,还需要额外注意事件流的生命周期管理:页面销毁时必须取消订阅,否则下次进入会重复注册,在 OpenHarmony 上更容易触发内存泄漏。
6.4 Flutter 引擎版本带来的“看起来正常但行为不同”的坑
OpenHarmony 上的 Flutter 行为并不是和 Android 完全一致的镜像。版本分支的选择会影响不少细节,比如键盘弹出、焦点管理、平台通道 API。如果你在项目里发现某个组件在 Android 上正常、在 OpenHarmony 上行为怪异,不要怀疑自己写错了,先看看是不是引擎版本导致的差异。
我的经验是:对不齐就接受差异,不要去硬调成和 Android 一模一样。比如 TabBar 动画,Android 上页面切换是流畅的,OpenHarmony 上可能会卡,那界面结构就改成 FilterChip 加列表刷新,不依赖转场动画。跨平台开发的最终目标是“行为可用”,不是“行为像素级一致”。
7. 做完整套模块后的一点个人心得
把书籍列表模块完整跑通之后,我最大的感受是:跨平台开发最难的部分不是框架本身,而是对目标平台差异的敬畏。OpenHarmony 上的 Flutter 已经能支撑像书籍列表这样核心的 UI 和交互,但它要求开发者主动避开“在 Android/iOS 上养成的惯性”,从环境搭建、插件选型到原生通道适配,每一步都要重新确认一遍。
我一直坚持的“纯 Dart 优先”原则经住了考验:这个项目里最稳定的部分,恰恰是没有依赖任何原生能力的部分。列表、模型、持久化、搜索,这些核心逻辑全部跑在 Dart 层,OpenHarmony 适配只让我额外花了少量时间在环境和签名配置上。后面我打算把封面文件管理、阅读时长统计这些能力逐步补进来,会优先看看有没有纯 Dart 方案,其次才考虑平台通道。
如果你正准备在 OpenHarmony 上用 Flutter 做一个工具型应用,我建议你从列表页开始,把一个最小闭环跑通:数据模型、列表展示、持久化、交互反馈。这一套完整跑下来,你对平台差异的感觉基本就建立起来了。踩坑记录里的那张速查表,也可以作为你上真机前的体检清单,先对照检查一遍,能省下不少调试时间。