news 2026/9/29 16:29:44

OpenHarmony上Flutter跨平台实战:看书记录App列表模块

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OpenHarmony上Flutter跨平台实战:看书记录App列表模块

把吃灰很久的一块 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 实现。

判断一个插件能不能用,我的顺序是这样的:

  1. 看它是否依赖原生能力,还是纯 Dart 实现。
  2. 看它的原生代码是否真的被触发,还是只在特定平台上走通道。
  3. 在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 样式
页面一返回就退出 AppNavigation 栈处理不当或返回键拦截缺失在页面里使用 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,肯定找不到。

排查通道问题的通用步骤我总结了三步:

  1. 确认 Flutter 侧 Channel 名称和原生侧完全一致,大小写必须一模一样。
  2. 确认 Dart 侧调用方法的异步回调有超时处理,不要无限等。
  3. 把原生侧实现先写一个日志输出,把能接到的调用打出来,再逐步加业务逻辑。

如果你要用 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 做一个工具型应用,我建议你从列表页开始,把一个最小闭环跑通:数据模型、列表展示、持久化、交互反馈。这一套完整跑下来,你对平台差异的感觉基本就建立起来了。踩坑记录里的那张速查表,也可以作为你上真机前的体检清单,先对照检查一遍,能省下不少调试时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 16:29:28

Wyse 3040瘦客户机固件升级与Horizon协议适配实战

1. 为什么100块的Wyse 3040不是“电子垃圾”&#xff0c;而是可复用的瘦客户机黄金备件你刷到这个标题时&#xff0c;第一反应可能是&#xff1a;“Dell Wyse 3040&#xff1f;那不是2016年就停产的老古董吗&#xff1f;100块买回来能干啥&#xff1f;当U盘挂件&#xff1f;”—…

作者头像 李华
网站建设 2026/9/29 16:26:49

Jetpack Compose SubcomposeLayout:测量驱动组合的自定义布局终极指南

在 Jetpack Compose 里做自定义布局&#xff0c;绝大多数场景一个 Layout 就够了&#xff1a;拿到子项&#xff0c;measure 一遍&#xff0c;然后按自己的规则摆放。但如果你遇到“要先知道文字实际占了多高&#xff0c;才决定要不要在右下角放一个展开按钮”“要根据每个标签…

作者头像 李华
网站建设 2026/9/29 16:26:37

数据依赖与数据库范式:从函数依赖到3NF/BCNF的规范化实战

有些场景其实不该无脑上3NF&#xff0c;这个我们后面再细说。1. 内容整体设计与思路拆解1.1 为什么先讲“数据依赖”而非直接讲“范式”我在带新人或者帮朋友团队做数据库评审的时候&#xff0c;发现一个很普遍的问题&#xff1a;很多人背得出三大范式&#xff08;1NF、2NF、3N…

作者头像 李华
网站建设 2026/9/29 16:26:31

Jev模型研究:从生成式到决策式的System One与RLCD校准实践

1. 从生成式到决策式&#xff1a;Jev 模型研究的核心命题拆解 第一次看到“Jev 模型”这个关键词的时候&#xff0c;我下意识以为是某个新出的开源大模型代号。翻了一圈资料才反应过来&#xff0c;Jev 并不是一个单纯的“模型名字”&#xff0c;它更像是一个研究方向的代称——…

作者头像 李华
网站建设 2026/9/29 16:26:29

特价股最大的定价变量:地缘科技与数字经济主权风险评估

1. 特价股定价&#xff1a;便宜从来不是白来的1.1 低价股的折扣&#xff0c;通常是被谁“定价”的特价股票往往是市场上最诱人的一块蛋糕&#xff0c;也是陷阱最密集的区域。市盈率低到个位数、市净率跌破1、股价距离高位腰斩&#xff0c;这些数字一摆出来&#xff0c;很多人的…

作者头像 李华
网站建设 2026/9/29 16:26:25

三菱CNC数据采集:为什么TCP是起点,A2 API是进阶

1. 为什么A2 API不是“开箱即用”&#xff0c;而TCP才是产线数据采集的真正起点在工厂自动化现场&#xff0c;我见过太多工程师拿着三菱CNC设备说明书&#xff0c;在“网络配置”章节反复划重点&#xff0c;却卡在第一步——连不上。他们默认A2 API是官方推荐的“标准接口”&am…

作者头像 李华