Flutter实战:追番看漫App UI设计与组件复用指南

发布时间:2026/8/31 12:57:17
Flutter实战:追番看漫App UI设计与组件复用指南 之前在做动漫类 App 时一直觉得“功能好做、UI 难磨”。尤其追番看漫这类产品核心用户都是年轻人首页排版、卡片间距、图片加载、播放页交互任何一处细节不到位评论区马上就会有人说“不好看、不想用”。最近在复盘一个名为 AFAN 的 Flutter 项目正好属于“追番 看漫”的二次元垂直场景。这篇文章就把这个项目的 UI 设计拆解、Flutter 实现思路、完整代码示例和几个高频坑点整理出来。如果你是 Flutter 新手或者正准备做内容型 App本文能帮你少走不少弯路。1. AFAN 是什么追番看漫 App 的 UI 设计思路AFAN 是一个典型的 ACGN 内容社区类应用核心功能是“追番”和“看漫”。所谓追番就是用户关注连载动画在每集更新后第一时间获取通知并观看所谓看漫则是提供漫画分类、阅读器和章节管理。这种 App 的 UI 设计有几个明显特征视觉风格偏向年轻化常用高饱和主色、圆角卡片、毛玻璃或暗色模式。信息密度高首页通常由轮播 Banner、分类入口、连载更新列表、热门推荐多个模块组成。图片资源多封面图、详情页大图、阅读器内页都需要良好的缓存与占位策略。交互路径长从首页点击到播放页/阅读器中间涉及详情页、选集页、历史记录等多个页面。用 Flutter 来实现这类 App有一件事非常关键不要一上来就堆组件。而是要先把页面结构拆成完整的 Widget 树再思考状态管理、图片加载和页面跳转。Flutter 的 UI 本质上是嵌套 Widget 的组合因此组件层级设计是否清晰直接决定了后期维护成本。在这篇文章里我们会从 UI 鉴赏的角度分析 AFAN 项目中的几个代表性页面然后进入实战用 Flutter 从零搭建一个可运行的“追番首页 漫画详情页”雏形。代码不会依赖后端接口数据用本地 Mock 模拟方便你直接复制运行。2. 环境准备与项目初始化开始之前先确认你的 Flutter 开发环境。不同版本的 Flutter 在组件命名和依赖版本上会有差异建议使用较新的稳定版本。需要准备的工具如下Flutter SDK本文示例以常见稳定版为例如果你用的是 3.x 版本基本可以直接运行。Android Studio 或 VS Code并安装 Flutter 与 Dart 插件。一台 Android 模拟器、iOS 模拟器或者直接连接真机进行调试。Git可选用于克隆项目或管理代码版本。如果你还没有安装 Flutter可以先去官网下载对应系统的 SDK 压缩包解压后把bin目录加入PATH环境变量。然后在终端执行flutter doctor这个命令会检查 Flutter 环境是否完整。常见输出项包括 Android toolchain、Xcode、Chrome 等只要没有出现红色的错误项基本就可以继续。接着创建一个新项目flutter create afan_ui cd afan_ui创建完成后目录结构是这样的afan_ui/ ├── android/ ├── ios/ ├── lib/ │ └── main.dart ├── pubspec.yaml └── README.md我们的主要代码都会写在lib/目录下。本文的 UI 示例不需要额外第三方依赖但如果你要做图片加载缓存、状态管理等能力可以在pubspec.yaml中添加依赖例如dependencies: flutter: sdk: flutter cached_network_image: ^3.3.0 provider: ^6.1.1版本号需要根据你的项目实际情况调整不要盲目复制。后续示例重点演示 UI 布局思路暂不引入复杂状态管理库。3. 追番 App 的 UI 拆解先设计再写代码在很多 Flutter 初学者眼里写 UI 就是从上往下写 Stack、Row、Column。这个思路在页面简单时没问题一旦页面复杂就会出现容器嵌套过深、状态散落、刷新逻辑混乱等问题。AFAN 项目的 UI 设计可以拆成三层来看。3.1 设计规范层主题色、字体、间距追番类 App 的主色调通常不会太素。AFAN 使用了一种偏紫罗兰的渐变色作为主色类似常见的二次元社区风格。在 Flutter 里我们通过ThemeData统一管理颜色、字体和组件样式避免每个页面单独写颜色值。一个比较规范的示例// lib/theme/app_theme.dart import package:flutter/material.dart; class AppTheme { static const Color primaryColor Color(0xFF7C4DFF); static const Color accentColor Color(0xFF00E5FF); static const Color bgColor Color(0xFFF7F8FC); static ThemeData light() { return ThemeData( primaryColor: primaryColor, scaffoldBackgroundColor: bgColor, appBarTheme: const AppBarTheme( backgroundColor: Colors.transparent, elevation: 0, centerTitle: true, titleTextStyle: TextStyle( color: Colors.black87, fontSize: 18, fontWeight: FontWeight.w600, ), ), cardTheme: CardTheme( color: Colors.white, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), ), ); } }这样设计的好处是后续所有页面都通过Theme.of(context)获取颜色而不是到处写死。3.2 组件层拆出可复用的卡片在追番首页最核心的 UI 组件是“番剧卡片”。同一个卡片会在“连载更新”“热门推荐”“我的追番”等多个模块出现如果每个页面各写一份代码量会失控。AFAN 的做法是把番剧卡片拆成一个独立 Widget接收标题、封面图、更新信息等参数。这样模块之间只需要传数据不需要关心卡片内部如何布局。// lib/widgets/anime_card.dart import package:flutter/material.dart; class AnimeCard extends StatelessWidget { final String title; final String coverUrl; final String episode; final double width; final VoidCallback? onTap; const AnimeCard({ super.key, required this.title, required this.coverUrl, required this.episode, this.width 120, this.onTap, }); override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: SizedBox( width: width, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(12), child: AspectRatio( aspectRatio: 3 / 4, child: Image.network( coverUrl, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Container( color: Colors.grey[200], child: const Icon(Icons.image_not_supported_outlined), ); }, ), ), ), const SizedBox(height: 8), Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), ), const SizedBox(height: 4), Text( episode, style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ), ), ); } }这段代码有几个细节值得注意AspectRatio保证了封面图固定为 3:4 比例不会因为图片原始尺寸不同导致卡片高度抖动。ClipRRect负责把图片裁剪成圆角视觉上更柔和。errorBuilder很重要因为网络图片可能加载失败没有占位图会导致一片空白。GestureDetector让整个卡片可点击而不是只点封面图。3.3 页面组装层关注数据流与刷新UI 鉴赏不只看静态截图还要看交互时的状态变化。AFAN 首页在切换 Tab、下拉刷新、点击追番按钮时页面局部内容要立即更新。一个常见的做法是使用RefreshIndicator包裹可滚动区域实现下拉刷新。使用FutureBuilder或状态管理库加载异步数据。使用ListView.builder渲染长列表避免一次性创建过多 Widget。下面来看一个完整的首页示例。4. 实战用 Flutter 构建追番首页与漫画详情页在这个实战里我们会完成两个页面追番首页包含顶部搜索栏、轮播 Banner、分类入口、连载更新列表。漫画详情页展示封面、简介、章节列表重点演示页面传参与路由跳转。4.1 创建项目结构在lib/目录下建议按照功能模块组织文件而不是把全部代码塞进main.dart。这里推荐一个轻量结构lib/ ├── main.dart ├── theme/ │ └── app_theme.dart ├── models/ │ ├── anime_item.dart │ └── comic_item.dart ├── widgets/ │ └── anime_card.dart ├── pages/ │ ├── home_page.dart │ └── comic_detail_page.dart └── mock/ └── data.dart这样分成模型、组件、页面、数据四层即使项目变大也比较容易继续扩展。4.2 定义数据模型先定义番剧和漫画数据模型。为了让代码简单我们使用factory构造函数从 Map 创建对象模拟网络返回的 JSON。// lib/models/anime_item.dart class AnimeItem { final String title; final String coverUrl; final String episode; final double score; const AnimeItem({ required this.title, required this.coverUrl, required this.episode, required this.score, }); factory AnimeItem.fromJson(MapString, dynamic json) { return AnimeItem( title: json[title] as String, coverUrl: json[coverUrl] as String, episode: json[episode] as String, score: (json[score] as num).toDouble(), ); } }// lib/models/comic_item.dart class ComicItem { final String title; final String coverUrl; final String author; final String intro; final ListString chapters; const ComicItem({ required this.title, required this.coverUrl, required this.author, required this.intro, required this.chapters, }); factory ComicItem.fromJson(MapString, dynamic json) { return ComicItem( title: json[title] as String, coverUrl: json[coverUrl] as String, author: json[author] as String, intro: json[intro] as String, chapters: (json[chapters] as List).castString(), ); } }模型层的好处是页面不直接依赖 Map 字符串而是使用强类型对象。将来接入真实接口时只需要替换数据源页面代码几乎不用动。4.3 准备 Mock 数据由于示例不依赖后端我们用本地数据模拟接口返回。// lib/mock/data.dart import ../models/anime_item.dart; import ../models/comic_item.dart; ListAnimeItem fetchAnimeList() { return [ AnimeItem( title: 星之回响, coverUrl: https://via.placeholder.com/300x400/7C4DFF/FFFFFF?textAFAN, episode: 更新至第12话, score: 9.2, ), AnimeItem( title: 深海列车, coverUrl: https://via.placeholder.com/300x400/00E5FF/333333?textAFAN, episode: 更新至第8话, score: 8.6, ), AnimeItem( title: 梦境便利店, coverUrl: https://via.placeholder.com/300x400/FF6B35/FFFFFF?textAFAN, episode: 已完结, score: 9.0, ), ]; } ListComicItem fetchComicDetail() { return [ ComicItem( title: 时光修理铺, coverUrl: https://via.placeholder.com/300x400/FF4081/FFFFFF?textCOMIC, author: 阿树, intro: 在城市角落有一家只在午夜营业的修理铺店主可以修复任何带有时光记忆的物品。, chapters: [第1话 锈迹怀表, 第2话 旧照片, 第3话 无声磁带], ), ]; }这里使用了占位图服务你可以替换成自己的图片地址。注意真实项目中建议使用 CDN 图片并配合cached_network_image做缓存减少流量消耗。4.4 实现追番首页首页是这个项目 UI 鉴赏的重点部分。它由四个横向区域组成顶部 AppBar显示 App 名称和搜索按钮。Banner 轮播区使用PageView实现。快捷入口区使用Row排列图标。连载更新区使用水平方向ListView展示番剧卡片。完整代码如下// lib/pages/home_page.dart import package:flutter/material.dart; import ../models/anime_item.dart; import ../mock/data.dart; import ../theme/app_theme.dart; import ../widgets/anime_card.dart; import comic_detail_page.dart; class HomePage extends StatelessWidget { HomePage({super.key}); final ListAnimeItem animeList fetchAnimeList(); override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: RefreshIndicator( onRefresh: () async { // 这里模拟刷新真实项目中可以重新请求接口 await Future.delayed(const Duration(milliseconds: 800)); }, child: ListView( padding: const EdgeInsets.symmetric(vertical: 8), children: [ _buildHeader(context), _buildBanner(context), _buildQuickEntry(context), _buildSectionTitle(连载更新), _buildAnimeList(), const SizedBox(height: 24), ], ), ), ), ); } Widget _buildHeader(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ const Text( AFAN, style: TextStyle( fontSize: 26, fontWeight: FontWeight.bold, color: AppTheme.primaryColor, ), ), const Spacer(), IconButton( onPressed: () {}, icon: const Icon(Icons.search), ), IconButton( onPressed: () {}, icon: const Icon(Icons.notifications_none), ), ], ), ); } Widget _buildBanner(BuildContext context) { final colors [ const Color(0xFF7C4DFF), const Color(0xFF00E5FF), const Color(0xFFFF6B35), ]; return Container( height: 160, margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: PageView.builder( itemCount: colors.length, controller: PageController(viewportFraction: 0.85), itemBuilder: (context, index) { return Container( margin: const EdgeInsets.symmetric(horizontal: 6), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), gradient: LinearGradient( colors: [colors[index], colors[index].withOpacity(0.7)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), child: Center( child: Text( Banner ${index 1}, style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, ), ), ), ); }, ), ); } Widget _buildQuickEntry(BuildContext context) { final entries [ (追番日历, Icons.calendar_month), (漫画库, Icons.menu_book), (排行榜, Icons.emoji_events), (历史记录, Icons.history), ]; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: entries.map((entry) { return Column( children: [ Container( width: 52, height: 52, decoration: BoxDecoration( color: AppTheme.primaryColor.withOpacity(0.1), borderRadius: BorderRadius.circular(16), ), child: Icon(entry.$2, color: AppTheme.primaryColor), ), const SizedBox(height: 6), Text(entry.$1, style: const TextStyle(fontSize: 12)), ], ); }).toList(), ), ); } Widget _buildSectionTitle(String title) { return Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Row( children: [ Text( title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const Spacer(), TextButton( onPressed: () {}, child: const Text(查看更多), ), ], ), ); } Widget _buildAnimeList() { return SizedBox( height: 220, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: animeList.length, itemBuilder: (context, index) { final anime animeList[index]; return Padding( padding: const EdgeInsets.only(right: 12), child: AnimeCard( title: anime.title, coverUrl: anime.coverUrl, episode: anime.episode, width: 120, onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) ComicDetailPage(title: anime.title), ), ); }, ), ); }, ), ); } }RefreshIndicator在 iOS 上表现得像原生橡皮筋效果在 Android 上则是标准的 Material 加载圆圈不需要额外区分平台。PageView.builder的viewportFraction设为 0.85可以呈现“下一张卡片露出一点”的滑动效果很多内容型 App 都采用这种设计。4.5 实现漫画详情页漫画详情页主要演示页面之间如何传参。封面、简介、章节列表如何布局。长列表场景下如何防止卡顿。// lib/pages/comic_detail_page.dart import package:flutter/material.dart; import ../models/comic_item.dart; import ../mock/data.dart; class ComicDetailPage extends StatelessWidget { final String? title; const ComicDetailPage({super.key, this.title}); override Widget build(BuildContext context) { // 这里省略了根据 title 查找数据的逻辑直接用第一个 mock 数据 final ComicItem comic fetchComicDetail().first; return Scaffold( appBar: AppBar( title: Text(comic.title), backgroundColor: Colors.transparent, ), body: ListView( padding: const EdgeInsets.all(16), children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(16), child: Image.network( comic.coverUrl, width: 110, height: 150, fit: BoxFit.cover, ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( comic.title, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), Text(作者${comic.author}), const SizedBox(height: 8), Row( children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: const Color(0xFFFF4081), borderRadius: BorderRadius.circular(20), ), child: const Text( 开始阅读, style: TextStyle(color: Colors.white), ), ), const SizedBox(width: 8), OutlinedButton( onPressed: () {}, child: const Text(加入书架), ), ], ), ], ), ), ], ), const SizedBox(height: 24), const Text( 简介, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text(comic.intro, style: const TextStyle(height: 1.5)), const SizedBox(height: 24), const Text( 章节列表, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), ...comic.chapters.map((chapter) { return ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.article_outlined), title: Text(chapter), trailing: const Icon(Icons.chevron_right), onTap: () { // 真实项目这里会跳转到阅读器页面 }, ); }), ], ), ); } }这里使用了 Dart 的 spread 操作符...comic.chapters.map(...)把章节列表展开成多个ListTile放入ListView。对于示例级数据量没有问题如果章节数量非常多建议改用ListView.separated或CustomScrollView避免一次性创建大量 Widget。4.6 运行入口最后修改main.dart// lib/main.dart import package:flutter/material.dart; import theme/app_theme.dart; import pages/home_page.dart; void main() { runApp(const AfanApp()); } class AfanApp extends StatelessWidget { const AfanApp({super.key}); override Widget build(BuildContext context) { return MaterialApp( title: AFAN 追番看漫, debugShowCheckedModeBanner: false, theme: AppTheme.light(), home: HomePage(), ); } }在终端执行flutter run如果一切正常你会看到一个包含 Banner、快捷入口、横向番剧卡片的首页。点击番剧卡片会跳转到对应的详情页。5. Flutter UI 开发中的常见问题与排查方法在编写 AFAN 这类 UI 密集型 App 时下面几个问题几乎是绕不开的。问题现象常见原因解决思路页面出现黄色双下划线溢出警告Row/Column 中内容超过屏幕宽度使用Expanded、Flexible包裹子组件或检查Text是否设置了maxLines网络图片加载时页面卡顿没有使用图片缓存使用cached_network_image配合memCacheWidth、memCacheHeight控制内存底部按钮被键盘顶起布局错乱Scaffold 的resizeToAvoidBottomInset默认行为不一致在不需要缩放的页面设置resizeToAvoidBottomInset: falseflutter run时提示 Gradle 构建失败Android 环境或依赖版本不匹配检查android/目录下的 Gradle 版本执行flutter clean后重试列表滚动时掉帧每帧重建了大量 Widget使用const构造函数、ListView.builder避免在build中创建耗时对象状态刷新后 UI 不更新修改数据后没有通知页面重建使用setState、ChangeNotifier或ValueNotifier管理状态下面详细展开几个高频问题的排查步骤。5.1 图片加载失败或显示空白不要直接依赖Image.network的默认行为。网络环境差、防盗链、图片链接失效都会导致加载失败。推荐做法是在errorBuilder中绘制占位图。使用loadingBuilder显示加载进度。配一张本地默认图作为兜底。示例Image.network( url, fit: BoxFit.cover, loadingBuilder: (context, child, progress) { if (progress null) return child; return Center( child: CircularProgressIndicator( value: progress.expectedTotalBytes ! null ? progress.cumulativeBytesLoaded / progress.expectedTotalBytes! : null, ), ); }, errorBuilder: (context, error, stackTrace) { return Container( color: Colors.grey[200], child: const Icon(Icons.broken_image_outlined), ); }, )5.2 渐变色卡片高度不一致如果 Banner 或卡片使用了LinearGradient又希望不同页面保持相同高度建议在外面固定高度而不是让子内容撑开。SizedBox( height: 160, child: Container( decoration: const BoxDecoration( gradient: LinearGradient(colors: [Color(0xFF7C4DFF), Color(0xFF00E5FF)]), ), ), )如果高度由内部文本决定可以给Text设置固定最大行数并配合Overflow处理。5.3 切换页面时主题色不一致多页面项目中如果你在某个页面单独写了TextStyle(color: Colors.blue)而全局主题是紫色就会出现风格割裂。建议全局统一使用Theme.of(context).colorScheme.primary等语义化颜色。例如Text( AFAN, style: TextStyle( fontSize: 22, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, ), )未来换主题色时只需要修改ThemeData不需要每个页面重新调整。5.4 下拉刷新无法触发如果你在ListView外包了RefreshIndicator但下拉没有反应通常是因为ListView没有内容或者滚动方向设置错误。RefreshIndicator只对“垂直方向可滚动”的组件生效。如果列表太短不足以滚动可以将physics: const AlwaysScrollableScrollPhysics()加到ListView上。ListView( physics: const AlwaysScrollableScrollPhysics(), children: [...], )6. 追番看漫 App UI 开发的最佳实践这部分是项目落地时比较有价值的总结。6.1 布局优先组件复用在写页面之前先画一个粗略的线框图把页面拆成几个大区块。AFAN 项目中首页被拆成了 Header、Banner、QuickEntry、SectionTitle、AnimeList 五个区块。每个区块都是一个独立方法或独立 Widget方便单独测试和复用。一个简单的判断标准如果一个布局块在页面中出现了两次以上就应该抽成组件。6.2 合理处理图片资源追番看漫 App 的图片数量远高于普通工具类 App。建议从项目一开始就建立图片加载规范列表页统一使用等比缩略图大小控制在 200 KB 以内。详情页可以加载原图但要使用渐进式加载或模糊占位。图片链接一定要走 HTTPS 和 CDN。添加失败重试机制避免用户看到大面积空白。6.3 使用语义化颜色与自适应布局不要把颜色值散落各处建议在主题文件中集中管理。对于不同屏幕宽度优先使用Expanded、Flexible、MediaQuery进行适配。避免写死宽高除非是头像、图标这类固定尺寸。6.4 注意动画性能Flutter 的动画非常灵活但过度使用会降低页面流畅度。AFAN 项目中建议注意以下几点Banner 自动轮播使用PageView的animateToPage而不是自定义 Timer 频繁setState。列表项进入动画不要超过 300 毫秒否则会拖慢首屏。避免在动画帧中执行网络请求、JSON 解析等耗时操作。6.5 路由与传参规范页面之间传参时直接通过构造函数传入即可不需要引入复杂路由插件。但如果页面参数较多建议定义一个Arguments类例如class AnimeDetailArguments { final String id; final String title; final String coverUrl; const AnimeDetailArguments({ required this.id, required this.title, required this.coverUrl, }); }使用时Navigator.push( context, MaterialPageRoute( builder: (context) AnimeDetailPage( arguments: AnimeDetailArguments(id: anime.id, title: anime.title, coverUrl: anime.coverUrl), ), ), );这样比逐层传递 Map 更安全也方便后续增加参数。6.6 日志与调试UI 开发中经常需要观察布局边界、检查 Widget 树。Flutter 自带两个很有用的开关debugPaintSizeEnabled true; // 显示组件边界 debugPaintLayerBordersEnabled true; // 显示图层边框使用方式import package:flutter/rendering.dart show debugPaintSizeEnabled; void main() { debugPaintSizeEnabled true; runApp(const AfanApp()); }上线前务必关闭这些开关。生产环境不要使用debugPrint打印大量日志应该接入日志框架并按级别过滤。7. 下一步可以继续完善的方向本文完成了 AFAN 追番首页和漫画详情页的 UI 雏形整体思路是“先定主题再拆组件后拼页面”。如果你希望把它扩展成一个更完整的项目可以从以下几个方面继续推进。第一接入真实接口。把 Mock 数据替换为http包或dio发起的网络请求并使用FutureBuilder处理加载态、错误态和数据态。第二引入状态管理。页面交互变多后可以选择Provider、Riverpod或Bloc。不要一上来就追求复杂方案先看页面状态是否存在跨模块共享。第三完善播放页与阅读器。追番 App 的视频播放页需要处理清晰度切换、记忆进度、弹幕等功能。漫画阅读器则需要横向翻页、缩放、预加载等能力。这两部分建议单独拆成独立模块开发。第四优化主题模式。为 App 增加暗色模式。追番用户经常在夜间使用暗色模式既是体验需求也是 UI 设计的重要组成部分。第五增加本地缓存能力。历史记录、追番状态、阅读进度都可以保存到本地数据库Flutter 中常用sqflite或drift实现。如果这篇文章对你有帮助可以收藏备用。后续我会继续整理 AFAN 项目中播放页、漫画阅读器和状态管理相关的实战笔记欢迎关注。