能做到第三篇说明环境、工程骨架、路由这些东西都已经趟过一遍了。这篇就很纯粹把首页立起来。首页这个东西说简单也简单无非是轮播图、快捷入口、歌单列表堆在一起说难也难状态管理、组件通信、网络请求、图片缓存、下拉刷新全都要在这一个页面里落地而且是在 Flutter for OpenHarmony 这套不算成熟的环境下落地。我做完首页之后最大的感受是这个页面是最适合用来练习工程化思维的地方——一个小小的首页能逼你把数据模型、全局状态和组件拆分都想清楚想不清楚后面做播放页、歌单详情页时一定返工。文章会顺着我实际开发的路径走先花时间把首页模块和数据结构定下来再选状态管理方案并处理组件通信然后逐个实现 UI 模块最后接网络层和兼容性修复。每一段都会把为什么这么做讲清楚。1. 首页功能拆解与数据模型设计很多人写首页犯的第一个错就是打开编辑器直接写 Widget。写一个静态的没问题一旦要接接口、做交互马上乱成一锅粥。我的习惯是把首页当成一个独立业务模块来设计先回答三个问题首页上到底要展示什么内容这些内容之间是什么关系数据从哪里来1.1 首页模块划分我这次设计的首页有五个核心模块顶部区域搜索框加用户信息入口搜索框承担站内搜索的跳转用户信息位展示登录状态。轮播 Banner三到五张运营图点击进入对应歌单或活动页面。快捷入口每日推荐、排行榜、分类歌单、收藏四个入口做成宫格。推荐歌单横向可滑动的歌单卡片列表每张卡片显示封面和播放量。猜你喜欢纵向列表展示歌曲名、歌手、专辑封面和播放按钮。这五个模块对应的数据格式差异很大轮播图需要图片地址和跳转目标快捷入口需要图标和路由名歌单需要封面和播放量歌曲列表需要作者信息。如果全部塞进一个HomeData对象里类会膨胀得很难维护所以我按模块拆成独立模型再用一个聚合模型统一持握。1.2 数据模型设计与统一接口我最终的数据模型结构是这样的class BannerItem { final String id; final String title; final String imageUrl; final String targetType; // playlist / album / url final String targetId; BannerItem({ required this.id, required this.title, required this.imageUrl, required this.targetType, required this.targetId, }); factory BannerItem.fromJson(MapString, dynamic json) { return BannerItem( id: json[id] as String, title: json[title] as String, imageUrl: json[imageUrl] as String, targetType: json[targetType] as String, targetId: json[targetId] as String, ); } }歌单模型的字段就是id、name、coverUrl、playCount这几个playCount要展示成1.2万这种格式我直接放在模型里提供 getter 方法不在 UI 层写格式化逻辑。class RecommendPlaylist { final String id; final String name; final String coverUrl; final int playCount; String get playCountText { if (playCount 10000) { return ${(playCount / 10000).toStringAsFixed(1)}万; } return playCount.toString(); } }聚合模型我命名为HomePageData注意我这里没有用HomeData这种模糊命名因为后面还可能有PlaylistDetailData、SongDetailData命名越具体越不容易撞车。HomePageData只做一件事持有各大模块的数据列表并向外提供fromJson工厂方法。class HomePageData { final ListBannerItem banners; final ListQuickEntry quickEntries; final ListRecommendPlaylist recommendPlaylists; final ListSongItem recommendSongs; HomePageData({ required this.banners, required this.quickEntries, required this.recommendPlaylists, required this.recommendSongs, }); }这里有个实际经验接口联调阶段一定会出现字段缺失或字段类型不对的场景比如后端临时把banners从数组改成了空对象。防御性写法是在fromJson里对可空字段做兜底比如json[banners] as List? ?? []。但我不建议所有字段都做兜底——核心字段做兜底会让你在联调时漏掉接口挂掉的问题我个人的做法是 UI 必用字段从严解析非核心推荐位字段做宽松处理。2. 状态管理方案Provider 的工程化组织方式首页最大的交互痛点在于状态共享Banner 轮播图的当前页需要在轮播组件和控制点组件之间同步播放器状态需要全局共享到首页的猜你喜欢列表。传统setState在这种场景下非常吃力我选了 Provider 作为状态管理方案并配合组件通信的规范化设计。2.1 为什么是 Provider 而不是 setState、Bloc 或 Riverpod这套选型逻辑不是拍脑袋定的我当时对比了几个主流方案方案上手成本代码量依赖关系OpenHarmony 兼容性setState 回调低多无无风险Provider中中少无风险Bloc高多中无风险纯 DartRiverpod中高中少无风险性能方面所有方案都是纯 Dart 实现在 OpenHarmony 上不存在原生依赖编译的问题。促使我选 Provider 的核心原因是它围绕ChangeNotifier构建和 Flutter 本身的Listenable机制是同一套底层模型而setState的问题是状态层级一深回调层层传递首页里 Banner 和快捷入口之间还算简单猜你喜欢里点播放按钮要通知底部播放条更新、再通知歌单详情页刷新播放进度这要是不用全局状态回调能把代码写成一团乱麻。2.2 Provider 的粒度控制这里要强调一下全局状态下文中不该什么都放。我见过不少工程把所有状态堆在一个GlobalStore里页面一重启全部数据 reload 一次完全是浪费。我的组织方式分两层全局级 StorePlaybackStore播放器状态、UserStore登录态显然需要全局持有。页面级 StoreHomeStore只为首页服务挂在首页的 Provider 节点上页面销毁时自动释放。具体实现上全局的 Store 在main.dart入口注册void main() { WidgetsFlutterBinding.ensureInitialized(); runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (_) PlaybackStore()), ChangeNotifierProvider(create: (_) UserStore()), ], child: const App(), ), ); }首页自己的状态则放在页面级在HomePage里注册class HomePage extends StatelessWidget { const HomePage({super.key}); override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) HomeStore()..load(), child: const HomeContent(), ); } }注意这里HomePage是StatelessWidget状态是ChangeNotifierProvider创建的因为 Provider 会负责 Store 的生命周期管理我不需要手动dispose。另外我在入口用了..load()让数据加载从页面初始化时就启动而不是等 build 之后才懒加载实际体验上会让用户觉得首页秒开。2.3 组件通信理清数据流向比语法更重要组件通信是热词里出现频率很高的问题。在 Provider 体系里其实没那么玄乎核心就三个场景第一父子组件传值。Parent 给 Child 传参数这是 Flutter 最基础的数据流方式轮播图组件接收ListBannerItem就是这种没有任何花活。第二跨页面共享状态。播放页修改PlaybackStore首页的猜你喜欢要同步显示播放状态、底部播放条要同步更新进度。解法就是上面说的把状态提升到全局 Store任何页面通过context.watchPlaybackStore()订阅。第三同页面兄弟组件同步。这是很多人真正懵的地方比如 Banner 轮播图和下方的指示器小圆点。我的做法是让轮播图组件把当前索引写进HomeStore指示器组件通过context.watchHomeStore()感知变化并重绘。本质上就是把兄弟通信转化为通过共享的 Store 通信这两个组件都读同一个数据源谁也不用管对方是谁。这里有个重要的编码习惯区分context.watch和context.read。需要响应变化的场景用watch比如指示器、播放条只在事件回调里拿数据的场景用read比如点击按钮后context.readPlaybackStore().play(song)如果你在回调里用watchWidget 会不必要的 rebuild首页这种列表多的页面很容易出现卡顿。3. 首页 UI 实现从骨架搭建到模块填充数据模型和状态管理定了之后UI 就是按部就班地填充。首页的整体骨架我用了CustomScrollView而不是简单的ListView因为它的Sliver体系能组合轮播图这种固定高度区域和歌单列表这种列表区域并且支持整页下拉刷新和快速滚动。3.1 页面骨架与顶部搜索栏首页的Scaffold我用了appBar但把AppBar的title换成了搜索框。搜索框是个装饰过的GestureDetector包Container点击后跳转到搜索页不是真的在里面输入文字。这样做的考虑是首页搜索框的搜索文字通常是推荐热词用户点击后直接进入搜索结果页更快真在首页搞个输入框反而多了一步操作。Scaffold( appBar: AppBar( titleSpacing: 16, title: GestureDetector( onTap: () Navigator.pushNamed(context, AppRoutes.search), child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: context.watchThemeStore().isDarkMode ? Colors.grey[800] : Colors.grey[200], borderRadius: BorderRadius.circular(18), ), child: Row( children: [ Icon(Icons.search, size: 20, color: Colors.grey[600]), const SizedBox(width: 8), Text(搜索周杰伦 / 民谣, style: TextStyle(color: Colors.grey[600], fontSize: 14)), ], ), ), ), ), body: const SafeArea(child: HomeContentList()), )这个组件用到了ThemeStore我的暗色模式状态也放进了全局 Provider。首页在实际运行时深浅色切换是即时响应的体验上会比MediaQuery.platformBrightnessOf更统一。3.2 轮播组件的实现细节轮播图核心是PageView.builder配合自动播放计时器。有些开源库是把自动播放逻辑写在同一个组件里我拆成了两层BannerView只管翻页和回调BannerIndicator只管显示当前页码的小圆点。中间状态的同步走HomeStore。class BannerView extends StatefulWidget { final ListBannerItem banners; const BannerView({super.key, required this.banners}); override StateBannerView createState() _BannerViewState(); } class _BannerViewState extends StateBannerView { late PageController _pageController; Timer? _timer; int _currentPage 0; override void initState() { super.initState(); _pageController PageController(viewportFraction: 0.92); _timer Timer.periodic(const Duration(seconds: 4), (timer) { if (!_pageController.hasClients) return; final nextPage _pageController.page!.toInt() 1; _pageController.animateToPage( nextPage, duration: const Duration(milliseconds: 400), curve: Curves.easeInOut, ); }); } override Widget build(BuildContext context) { return SizedBox( height: 140, child: PageView.builder( controller: _pageController, itemCount: widget.banners.length, onPageChanged: (index) { final store context.readHomeStore(); store.setBannerIndex(index); }, itemBuilder: (context, index) { final banner widget.banners[index]; return Container( margin: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), image: DecorationImage( image: NetworkImage(banner.imageUrl), fit: BoxFit.cover, ), ), ); }, ), ); } override void dispose() { _timer?.cancel(); _pageController.dispose(); super.dispose(); } }轮播图代码里有两个关键点。第一viewportFraction: 0.92这样左右两个相邻 Banner 会露出一点边缘视觉上有C 位的层次感比整页占满更精致。第二Timer.periodic里判断了hasClients因为用户滑动过程中控制器可能还没挂载完成不判断会直接崩。我在实机上发现的一个问题OpenHarmony 设备上的PageController.page在快速滑动时可能返回旧值无限轮播翻到边界后页码错位。我的解决办法是只用取模逻辑来保证边界store.setBannerIndex(index % widget.banners.length);这样无论PageView内部滑出去几页BannerIndicator显示的永远是对应当前真实页的指示点。3.3 快捷入口与推荐歌单列表快捷入口我用GridView实现禁用了滚动固定在首页顶部。这个区域用GridView的好处是自动计算间距和对齐不用自己算宽度。四个入口分别是每日推荐、排行榜、分类歌单、私人 FM每个入口是一个图标加文字的InkWell点击后通过命名路由跳转。推荐歌单列表的实现也是一个容易翻车的地方。很多方案是用ListView嵌套水平ListView这会导致高度冲突运行时报RenderBox was not laid out。我的做法是将水平列表包在固定高度SizedBox中SizedBox( height: 170, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: playlist.length, separatorBuilder: (_, __) const SizedBox(width: 12), itemBuilder: (context, index) { final item playlist[index]; return SizedBox( width: 120, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Stack( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( item.coverUrl, width: 120, height: 120, fit: BoxFit.cover, ), ), Positioned( right: 4, bottom: 4, child: Row( children: [ Icon(Icons.play_arrow, size: 14, color: Colors.white), Text(item.playCountText, style: const TextStyle(color: Colors.white, fontSize: 10)), ], ), ), ], ), const SizedBox(height: 6), Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis), ], ), ); }, ), )这种封面卡片上叠加播放量的Stack布局在音乐类 App 里是标配。注意PlaybackStore的状态可以从这个入口被监听比如当前正在播放的歌单它的卡片上可以加一个正在播放的标注这个我放到了HomeStore里面。3.4 猜你喜欢与整页下拉刷新猜你喜欢列表是纵向ListView的shrinkWrap: true模式放在CustomScrollView里作为最后一个SliverList。每条歌曲项我拆成了独立的SongListItem组件里面支持点击整行播放、点播放按钮单独操作。整页下拉刷新的实现用RefreshIndicator包住CustomScrollView刷新回调里重新请求首页数据。class HomeContentList extends StatelessWidget { const HomeContentList({super.key}); override Widget build(BuildContext context) { final homeStore context.watchHomeStore(); return RefreshIndicator( onRefresh: () homeStore.refresh(), child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverToBoxAdapter( child: Column( children: [ if (homeStore.banners.isNotEmpty) BannerView(banners: homeStore.banners), const BannerIndicator(), const QuickEntryGrid(), const SectionHeader(title: 推荐歌单), _buildPlaylistShelf(homeStore.recommendPlaylists), ], ), ), if (homeStore.recommendSongs.isNotEmpty) SliverList.builder( itemCount: homeStore.recommendSongs.length, itemBuilder: (context, index) SongListItem(song: homeStore.recommendSongs[index]), ), ], ), ); } }注意AlwaysScrollableScrollPhysics是必须的否则内容不满一屏时下拉手势不生效RefreshIndicator就成了摆设。这是我从 OpenHarmony 实机上踩出来的教训。4. 首页数据接入网络请求封装与 JSON 解析状态管理和 UI 都就位后首页最核心的工作就是接数据了。我用的是dio在 OpenHarmony 上运行没有兼容问题因为它是纯 Dart 实现的 HTTP 库不依赖任何平台原生代码。4.1 dio 实例配置网络层我封装了单例DioManager配置超时时间和拦截器。超时时间我用的是connectTimeout: 10000和receiveTimeout: 10000单位是毫秒。对于移动网络环境不稳定的场景超过 10 秒还连不上服务器基本可以断定是网络问题再怎么等也没什么意义。class DioManager { static final DioManager _instance DioManager._internal(); factory DioManager() _instance; DioManager._internal() { dio Dio(BaseOptions( baseUrl: ApiConfig.baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), )); dio.interceptors.add(LogInterceptor(responseBody: true)); } late final Dio dio; }这里有个值得注意的版本差异dio5.x 之后的connectTimeout参数从int改成了Duration类型。网上大量旧教程用的还是connectTimeout: 10000这种写法会导致类型报错。如果你从老项目迁移过来务必检查版本。LogInterceptor在开发和测试阶段打开看响应日志很方便。但发布到生产环境前一定记得关闭否则接口参数和返回数据都会暴露在日志里在 OpenHarmony 上出问题时会增大排查噪音。4.2 首页数据请求与状态流转数据加载流程我分成了加载中、加载成功、加载失败三种状态。HomeStore 里维护一个枚举状态UI 根据状态显示 loading、内容区或错误重试。enum HomeLoadStatus { initial, loading, success, error } class HomeStore extends ChangeNotifier { ListBannerItem _banners []; ListQuickEntry _quickEntries []; ListRecommendPlaylist _recommendPlaylists []; ListSongItem _recommendSongs []; HomeLoadStatus _status HomeLoadStatus.initial; String _errorMessage ; Futurevoid load() async { _status HomeLoadStatus.loading; notifyListeners(); try { final response await DioManager().dio.get(/api/home); final data HomePageData.fromJson(response.data[data]); _banners data.banners; _quickEntries data.quickEntries; _recommendPlaylists data.recommendPlaylists; _recommendSongs data.recommendSongs; _status HomeLoadStatus.success; } catch (e) { _status HomeLoadStatus.error; _errorMessage e.toString(); } notifyListeners(); } }这里的notifyListeners()在load()的结尾只调用了一次把成功和错误两种状态一起通知出去UI 层只需要根据_status走分支。很多初学者会在 try 和 catch 里各调用一次notifyListeners()其实结果是一样的但重复调用会触发多余的 UI 重建。4.3 模型字段与后端 JSON 不一致的处理联调中遇到最多的坑是后端返回字段语义不统一。比如playCount同一个接口内容列表里是整数但 Banner 里的跳转参数targetId是字符串还有的字段干脆为 null。我的经验是在fromJson里做类型收敛而在 UI 层不要做任何解析。一个比较实用的模式是json[playCount] as num? ?? 0再.toInt()这种写法对int和double类型都兼容final playCount (json[playCount] as num?)?.toInt() ?? 0;另外 OpenHarmony 真机上的应用可能没有完整的 CA 证书链遇到HandshakeException的概率比 Android 设备高一些。我遇到过一次排查到最后发现是服务器的中间证书没配全而不是应用代码的问题。建议接入 HTTPS 接口前先确认证书链完整性或者调试时在 Dio 里临时关掉证书校验但发布前一定要关掉这个开关。5. OpenHarmony 环境下的兼容性调整与典型坑在 OpenHarmony 上跑 Flutter 和在其他平台上不一样很多平时不太注意的底层问题都会冒出来。我把这个过程中踩过的坑集中记录下来这部分对正在做 Flutter for OpenHarmony 开发的同行来说应该最有用。5.1 Impeller 渲染引擎的兼容问题Flutter 3.7 之后默认开始推 Impeller 渲染引擎替代老旧的 Skia。在 Android 和 iOS 上Impeller 已经比较成熟了但如果你的 Flutter for OpenHarmony 版本低于官方适配基线或者适配分支里 Impeller 的支持还没到位首页跑起来会出现两种明显病状第一文本渲染异常整体字体发虚像加了层雾第二页面切换动画掉帧轮播图滑动有撕裂感。我在 OpenHarmony 开发板上一开始就遇到这个问题后来检查发现渲染引擎默认启用了 Impeller而当前 OpenHarmony 工具链对它的支持还没完全跟上。解决办法是回退到 Skia 渲染。在 Flutter 引擎启动参数里加上关闭 Impeller 的配置具体在engine_args或 channel 参数里设置--enable-impellerfalse不同版本的适配分支配置位置略有差别但思路是统一的。验证方式很简单跑起来一个页面观察字体模糊是否消失。如果你在 OpenHarmony 上跑 Flutter 应用时文字渲染异常优先怀疑 Impeller而不是字体文件加载。5.2 Gradle 插件方式变更项目新建后如果采用了新版 Flutter 工具链在构建时可能会遇到这样的错误提示You are applying Flutters main Gradle plugin imperatively using the apply这个报错的意思是新版 Flutter Gradle 插件要求使用声明式方式plugins {}块加载而旧工程模板还在用apply plugin的方式。OpenHarmony 的 Flutter 工具链在同步上游 Flutter 版本时把这个规则也带过来了。修复方式需要动两处 Gradle 配置。第一处settings.gradle里启用插件管理pluginManagement { plugins { id dev.flutter.flutter-plugin-loader version 1.0.0 id com.android.application version 8.1.0 id org.jetbrains.kotlin.android version 1.8.22 } }第二处项目根build.gradle里改成plugins { id dev.flutter.flutter-plugin-loader version 1.0.0 id com.android.application version 8.1.0 apply false id org.jetbrains.kotlin.android version 1.8.22 apply false }改完之后同步 Gradle再跑flutter build hap就能正常通过。这个问题在 Android 平台讨论得很多但在 OpenHarmony 上实际遇到时因为环境矩阵不熟悉反而更容易懵。5.3 Dart VM 初始化异常与平台通道错误热词里有一条[error:flutter/runtime/dart_vm_initializer.cc(41)] unhandled ... exception这是 OpenHarmony 设备常见的报错前缀通常后面会跟着一个具体的异常类型。我在做首页时遇到过两次一次是平台通道调用时机问题。当时我在一个页面的initState里调用了MethodChannel去查询系统音量但页面的onDetach回调先一步执行了导致通道尚未注册时就发起了调用。修复方案是调用前判断channel.binaryMessenger ! null或者把调用时机延后到addPostFrameCallback。另一次是 JSON 解析失败。后端在某次发布中把某个id字段从字符串改成了数字我的json[id] as String就直接崩了。这类问题如果只看崩溃日志会非常难定位因为日志只显示type int is not a subtype of type String这种类型转换异常但不知道在哪条数据上。后来我在解析层统一加了字段类型检查日志在 debug 模式下打印解析失败的 JSON 片段定位速度立刻提上来了。5.4 页面引擎的 AAR 集成模式如果你在 OpenHarmony 工程里想以组件方式集成 Flutter 页面可能会看到flutter aar或flutter build aar这类程序。flutter build aar是 Flutter 为原生 Android 工程提供的产物生成模式但在 OpenHarmony 上应用最终构建产物通常是 HAP不是 APK。我个人的建议是在 Flutter for OpenHarmony 的早期开发阶段优先以纯 Flutter App 的方式构建减少混合工程的变量。等 Flutter 页面稳定之后再考虑通过 OpenHarmony 的混合工程机制嵌入原生页面。一来纯 Flutter 的调试链最短二来你只需要关注 Dart 层的问题不需要同时排查 Java/ArkTS 和原生构建链路。我遇到过有人把 Android 工程的 AAR 集成方式原样套到 OpenHarmony 上结果在构建 HAP 时一直报产物不匹配。这属于典型的方案错位方向搞错了后面再怎么调都是在浪费时间。5.5 新建项目的跑不起来问题热词里还有一条flutter新建项目后 跑不起来这个坑大概率出现在环境配置上。Flutter for OpenHarmony 必须要找到对应版本的 OpenHarmony SDK 工具链。如果你的flutter doctor没有正确识别 OpenHarmony 设备或者构建时提示找不到 SDK可以先手动配置flutter config --ohos-sdk/path/to/ohos/sdk配置完再执行flutter doctor -v确认OpenHarmony那一项是绿色的。新手经常犯的错是只配了 Android SDK 环境就开工代码写完了才发现 OpenHarmony 工具链没就绪。首页开发这种高频迭代的阶段环境问题一定要提前一劳永逸地解决不然后面每一次构建都是一次折磨。6. 首页性能与体验优化首页模块多、图片多在 OpenHarmony 开发板上如果不管性能分分钟掉帧。这个阶段我做了一些针对性优化效果很直观。6.1 图片处理的实战用法音乐 App 首页全都是图。轮播图是大图歌单是方图歌曲列表是小图。如果在 UI 层直接写Image.network每个图片组件都要自己处理 loading、error、缓存代码重复度高而且会在快速滚动时出现白屏闪烁。我临时用的是cached_network_image插件做网络图缓存。但这里有个 OpenHarmony 上的注意事项cached_network_image依赖flutter_cache_manager做磁盘缓存缓存目录的获取依赖原生路径能力在 OpenHarmony 上可能出现缓存目录获取失败。如果遇到图片一直加载但不显示的问题检查缓存目录是否正常必要时本地替换成手动缓存方案。实际经验我在做首屏时优先把 Banner 图和推荐歌单封面提前请求好并缓存这样首帧渲染快二次进入几乎秒开。图片的渐入效果可以靠FadeInImage或给Image.network的frameBuilder加透明度动画。6.2 列表滚动性能首页是一个长页面滚动过程中如果每条歌单组件里都做复杂计算帧率会直线下降。优化点有几个第一推荐歌单列表的itemBuilder里避免创建重复对象比如BoxDecoration和BorderRadius这些配置常量提前声明成 static final而不是每次 build 都new一遍。const构造能省则省。第二把 Card 阴影开销大的布局换成简单的边框和浅色背景。在低端 OpenHarmony 设备上阴影是渲染的大敌能不用就不用。第三给超长的猜你喜欢列表条目加RepaintBoundary。每个SongListItem我外层包了RepaintBoundary列表滚动时只重绘出现变化的部分而不是整屏。这个改动对帧率提升非常明显。6.3 首屏加载与骨架屏接口请求需要时间尤其是弱网环境首屏直接空白会让人觉得 App 卡死了。我给首页加了骨架屏在数据未返回时用灰色块画出 Banner、宫格、列表的占位轮廓。实现方式不复杂就是根据HomeStore.status判断loading状态渲染HomeSkeletonsuccess渲染HomeContentListerror渲染重试按钮。骨架屏的动画用Shimmer效果会更好但考虑到包体积和复杂度我用了纯色渐变同时用AnimatedOpacity做切换透明度视觉上也不突兀。这里想多提醒一句架构上把加载状态、错误状态、空数据状态都提前定义好比业务代码写完再补要轻松得多。我的首页在接入真实接口后因为状态结构提前铺好了只改了一处load方法内的数据赋值逻辑就完事了UI 层完全没动。7. 排查链路示例从首页白屏到定位根因开发过程中一定会出问题这里分享一次典型的首页白屏排查过程完整走一遍链路比直接给答案更有价值。那天的现象是首页能打开AppBar 标题显示正常但下方整块区域空白没有报错。第一步我检查了HomeStore的状态。打印后发现状态是HomeLoadStatus.success说明接口请求返回成功、数据解析没抛异常。那问题就不在网络层而在 UI 层的数据渲染。第二步我在HomeContentList里加了日志打印homeStore.banners.length和homeStore.recommendPlaylists.length结果显示 banners 长度是 0playlists 长度是 0。也就是说数据对象是空列表。第三步回头看接口返回的原始 JSON。打印response.data发现后端把首页数据包在一个result字段里而我解析时用的是response.data[data]拿到的是 null。第四步修正解析路径改成response.data[result][data]。为了兼容后端还可能会调整结构我在DioManager里统一加了一个unwrap方法专门处理多层包壳的逻辑。这样以后接口文档变了只需要改一个地方。这次排查的收获是数据解析失败不一定抛异常很多时候是静默降级成空列表UI 层看到空数据什么都不渲染表现就是白屏。排查这类问题一定不要只看报错要一层层打日志验证网络层有没有数据、解析层有没有字段、UI 层有没有消费到。最后再分享一个小技巧不要把全部首页请求都串行等待。Banner、推荐歌单、猜你喜欢这三个模块的数据其实相互独立我在load()里用了Future.wait并行请求整体首屏耗时降了将近一半。如果你也在做 Flutter for OpenHarmony 的播放器建议先把这页的模块边界和状态结构想透再动手首页稳了后面页面开发基本就是复制经验。
阅读完成 · 觉得有帮助?