1. 为什么第一个实战项目我选了一个"信息流小应用"而不是再写一个 Todo
我发现很多刚接触 Flutter 的朋友,学完基础语法之后会卡在同一个地方:教程里的每个知识点都看懂了,Button、ListView、网络请求都单独跑通过,但一说到"做个自己的项目",就完全不知道从哪下手。这个系列就是为了解决这个问题。
这次实战我给自己定的题目很简单——做一个可用的信息流阅读应用,不做复杂特效,不堆砌花哨交互,但必须把真实 App 的骨架搭完整:启动页、列表页、详情页、网络请求、加载状态、错误处理、本地缓存、下拉刷新、上拉加载、打包配置。为什么选这个方向而不是再写一个经典 Todo?原因很实际:
第一,信息流是最常见、最不挑领域的应用形态。选它能保证读者把代码跑起来之后,能立刻想象到"这东西换个数据源就能变成新闻阅读器、商品列表、开源项目导航"。
第二,它天然覆盖 Flutter 的核心知识点。列表渲染对应 ListView.builder,网络层对应 dio/http 封装,异步状态对应 FutureBuilder(以及后续的 Riverpod),页面跳转对应命名路由,本地缓存对应 shared_preferences 或文件存储。做一遍等于把 Flutter 日常开发的高频场景全部过了一遍。
第三,它足够小。一个人一个周末能做出来,不会因为工程太大半途而废。
我见过太多的学习路径是:每个组件学三遍,项目一个都没做完整过。这次我反过来——直接开干,遇到什么补什么。
提示:这篇文章是"项目实战第一弹",我会把从零到发布的全过程都写出来。如果你还在犹豫"我现在水平够不够",先别纠结,完整跟一遍,你会发现自己能走通的路比想象中长。
2. 开工前我先把目录结构和依赖一次定好
2.1 为什么一开始就要定目录结构,而不是写到哪里算哪里
很多初学者最喜欢在 main.dart 里一口气写五百行,跑通了再说。这种做法在五十行以内问题不大,一旦项目超过两三个页面,就会发现自己陷入"改一个变量满文件搜"的困境。这次实战从一开始我就把目录按功能拆分,目标很明确:每个文件只干一件事,通过命名快速定位。
我先定义了一个标准分层结构:
lib/ ├── main.dart # 入口,负责初始化 ├── app.dart # 根组件,负责主题和路由 ├── constants/ # 全局常量、API地址、存储key ├── models/ # 数据模型 ├── services/ # 网络请求、数据服务 ├── providers/ # 状态管理控制器 ├── pages/ │ ├── home/ # 首页相关 │ ├── detail/ # 详情页相关 │ └── splash/ # 启动页相关 └── widgets/ # 通用组件这样拆分的直接收益是:等代码量过了两千行,你仍然能在十秒内定位出一个组件或接口的位置。后续加页面、加功能也只是往对应目录里放文件,不需要动其他模块。
2.2 依赖选型的思路
依赖选型我遵循"够用、稳定、生态好"三个原则。下面是最终在 pubspec.yaml 里锁定的关键依赖和选型理由:
| 依赖 | 用途 | 选型理由 |
|---|---|---|
| dio | 网络请求 | 拦截器机制成熟,可统一处理日志、错误码、token;社区使用量最大 |
| riverpod | 状态管理 | 编译期安全,调试友好,不需要 BuildContext 就能读取状态,适合中大型项目 |
| shared_preferences | 本地键值存储 | 轻量,适合存设置项和简单缓存元数据 |
| cached_network_image | 网络图片缓存 | 自带内存+磁盘二级缓存,列表图片滚动时省流量不卡顿 |
| flutter_screenutil | 屏幕适配 | 按设计稿尺寸自动换算,省去不同机型手工调尺寸的麻烦 |
| intl | 日期格式化 | 官方维护,格式化相对时间很顺手 |
有一个容易忽略的点:版本号我统一用了脱字符(^)限定大版本,不锁死具体小版本。这样 pub get 的时候能拿到兼容范围内的最新版,同时避免大版本升级带来的破坏性变化。
2.3 避免把依赖版本"一直跟着最新走"
我知道有朋友习惯直接flutter pub add xxx,然后一路 yes。跑是能跑,但等过了两三个月,项目里可能会出现依赖间的兼容性冲突——你装 A 的新版,结果 B 的接口变了。我的做法是:每次新增依赖之前先在 pub.dev 上看一眼它的更新时间、最后版本发布时间、以及 README 里有没有提到 breaking change。稳,比新重要。
3. 主题、路由、配置:这几块决定了后面写代码的"手感"
3.1 全局主题的配置方式
一个信息流应用,主题不需要复杂,但配色和字体的统一是基本功。我在 constants 目录下建了app_theme.dart,用 Flutter 自带的ThemeData把三套东西统一管理起来:
- 颜色:主色、背景色、卡片色、文字主次色
- 文本样式:标题字号、正文行高、辅助文字颜色
- 通用组件样式:卡片圆角、阴影、AppBar 返回按钮风格
实际效果是:页面里几乎不写TextStyle(fontSize: 14)这种散装样式,全走主题引用。后面想要换夜间模式或者调整品牌色,只改一个文件,全 App 生效。
这里分享一个细节:很多人会在MaterialApp里同时设置theme和darkTheme,但在项目初期其实没必要,先保证一套主题足够统一,比提前做多皮肤更重要。多皮肤会引入大量边界判断,前期只会拖慢进度。
3.2 命名路由和页面跳转的取舍
Flutter 里页面跳转有两条路:Navigator.push直接传页面实例,或者用命名路由。我在这次项目里用了后者。
routes: { '/': (context) => const SplashPage(), '/home': (context) => const HomePage(), '/detail': (context) => DetailPage(argument: argument), }命名路由的好处是:跳转时只需要拼字符串,代码里不会出现大量import页面类的引用;而且在需要做登录拦截、深链跳转时会方便很多。当然,直接传参数拼路由字符串有一个困扰——参数多了怎么办?我的处理方式是定义一个DetailArgument类,把列表页传给详情页的所有字段打包成一个对象,路由只传这一个对象。这样点进详情页时不会因为参数列表太长而眼瞎。
3.3 全局配置独立成文件
接口地址、缓存过期时间、分页大小、图片占位图,这些"环境级常量"我集中放在了constants/app_config.dart里。用abstract final class包一层静态常量,后面切换到正式环境,只需要改一个文件里的 API baseUrl。
abstract final class AppConfig { static const String apiBaseUrl = 'https://api.example.com'; static const int pageSize = 20; static const Duration cacheExpire = Duration(days: 1); }这个习惯再往后延伸到多环境构建时,你只需要配合--dart-define就能实现测试、预发、正式三套环境的切换。现在先把配置文件独立出来,等于给未来留了条后路。
4. 数据模型和网络层:先把"数据能不能顺畅进来"解决掉
4.1 模型设计:少一点手写,多一点规范
信息流应用的核心数据,无外乎一个文章摘要对象:封面图、标题、摘要、发布时间、内容正文。我定义模型时遵循了一条很实用的原则:模型的职责只有两个——序列化和反序列化。所有业务逻辑都不写在模型类里。
class ArticleModel { final String id; final String title; final String summary; final String coverUrl; final String content; final DateTime publishedAt; const ArticleModel({ required this.id, required this.title, required this.summary, required this.coverUrl, required this.content, required this.publishedAt, }); factory ArticleModel.fromJson(Map<String, dynamic> json) { return ArticleModel( id: json['id'] as String, title: json['title'] as String, summary: json['summary'] as String, coverUrl: json['cover_url'] as String? ?? '', content: json['content'] as String? ?? '', publishedAt: DateTime.tryParse(json['published_at'] as String? ?? '') ?? DateTime.now(), ); } Map<String, dynamic> toJson() { return { 'id': id, 'title': title, 'summary': summary, 'cover_url': coverUrl, 'content': content, 'published_at': publishedAt.toIso8601String(), }; } }注意几个细节:coverUrl和content我都给了空字符串兜底,而不是直接as String强转,因为真实后端返回的数据经常缺字段,空值处理不当会在渲染层崩掉;publishedAt用了tryParse加兜底当前时间,避免偶发的时间格式解析崩溃。
4.2 dio 封装:把拦截器用到极致
raw 的 dio 使用方式很简单,但一进入项目实战,我会立刻做一层封装。不为了抽象而抽象,而是为了三个非常现实的需求:
- 统一设置超时时间和请求头,不用每个请求重复写
- 统一处理业务错误码,把"请求成功但业务失败"和"网络异常"分开
- 统一打印日志,接口调试的时候用得上
class ApiClient { ApiClient._internal() { _dio = Dio( BaseOptions( baseUrl: AppConfig.apiBaseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), headers: {'Accept': 'application/json'}, ), ); _dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) { // 这里可以统一加 token handler.next(options); }, onResponse: (response, handler) { final code = response.data['code']; if (code != 0) { handler.reject(DioException( requestOptions: response.requestOptions, message: response.data['message'] ?? '业务错误', type: DioExceptionType.badResponse, )); return; } // 接口真正的数据藏在 data 里 response.data = response.data['data']; handler.next(response); }, onError: (error, handler) { // 统一异常提示文案 handler.next(error); }, ), ); } static final ApiClient instance = ApiClient._internal(); final Dio _dio; Dio get dio => _dio; }这套封装最常用的场景其实是后面调试阶段——开代理抓包时需要加自定义 header,改动只落在onRequest一处,全 App 生效。我建议每个人在项目初期都做类似封装,后面省下的时间远超前期花费的时间。
4.3 数据服务层的另一个职责:把数据"喂"给页面之前先处理成页面想要的形状
有些团队会把 service 层做得很薄,直接返回模型列表。我在这次项目里除了返回模型,还封装了一个重要的能力:错误转换成用户可读的提示。比如网络断开时,Service 层会返回一个ApiException,里面带上message;UI 层拿到这个异常后直接展示在错误视图上,不用做二次判断。
class ArticleService { Future<List<ArticleModel>> fetchArticles({required int page, int pageSize = AppConfig.pageSize}) async { try { final response = await ApiClient.instance.dio.get( '/articles', queryParameters: {'page': page, 'page_size': pageSize}, ); final listJson = response.data as List<dynamic>; return listJson .map((e) => ArticleModel.fromJson(e as Map<String, dynamic>)) .toList(); } on DioException catch (e) { throw ApiException( message: e.type == DioExceptionType.connectionTimeout ? '网络有点慢,请稍后再试' : '加载失败,请检查网络', ); } } }这样 UI 层永远只面对两种结果:成功拿到模型列表,或者拿到一个"用户能看懂"的错误文案。判断逻辑不散落在页面各处,长期维护的体验会好很多。
5. 状态管理用 Riverpod,但真正复杂的是页面里怎么组织状态
5.1 为什么状态管理从 Provider 换成了 Riverpod
如果你早期用过 Flutter 官方的 Provider,能明显感觉到它简单,但也有几个让人别扭的痛点:依赖 BuildContext 才能读状态,测试的时候必须包一层 Provider 结构。Riverpod 最大的改观是:Provider 声明和组件树解耦了,任何地方都能通过 ref 读到状态,代码组织起来更接近"依赖注入"的思路。
我在这次项目里用到的全局状态只有两类:文章列表的异步加载状态、以及当前的请求分页参数。看起来不多,但 Riverpod 在处理"页面销毁后状态要不要保留"这个问题上非常顺手。缓存的是 Provider 实例化的对象,可以跨页面保持,这对详情页从列表页读取数据缓存很有用。
5.2 AsyncNotifier 的写法:省掉所有手动 setState
我实际上用的是AsyncNotifier,因为它把"加载中、成功、失败"三个状态统一塞进了一个AsyncValue里。页面里只需要看AsyncValue当前的快照来决定渲染加载动画、错误视图还是列表。
class ArticleListNotifier extends AsyncNotifier<List<ArticleModel>> { int _page = 1; bool _hasMore = true; @override Future<List<ArticleModel>> build() async { return _fetchPage(); } Future<List<ArticleModel>> _fetchPage() async { final list = await ref.read(articleServiceProvider).fetchArticles(page: _page); return list; } Future<void> refresh() async { _page = 1; _hasMore = true; state = const AsyncLoading(); state = await AsyncValue.guard(_fetchPage); } Future<void> loadMore() async { if (_hasMore) { final next = await ref.read(articleServiceProvider).fetchArticles(page: _page + 1); if (next.isEmpty) { _hasMore = false; return; } state = AsyncData([...?state.value ?? [], ...next]); _page++; } } }注意refresh()里我用了AsyncValue.guard而不是自己写 try-catch,这是 Riverpod 提供的便捷方法,能自动把异常包成AsyncError,UI 层拿到它直接展示错误视图,方便得很。
5.3 列表页的 UI 组装:边角状态几乎比主要逻辑还多
一个信息流首页,正常状态是列表,除此之外还要处理空列表、初次加载中、刷新失败、加载更多失败、加载到底部等状态。大多数人写列表页只放一个 ListView,结果一旦网络慢或断网,用户体验就很糟。
我的做法是:用AsyncValue.when分三路渲染,再在error分支里区分"是否已有历史数据"。如果当前state.value不为空,说明是刷新失败,就保留列表并在顶部弹个 SnackBar;如果state.value为空,说明是首次加载失败,才展示全屏错误视图。
final articlesAsync = ref.watch(articleListProvider); return articlesAsync.when( data: (articles) => RefreshIndicator( onRefresh: () => ref.read(articleListProvider.notifier).refresh(), child: ListView.builder( itemCount: articles.length + 1, itemBuilder: (context, index) { if (index >= articles.length) { return const _NoMoreFooter(); } return ArticleCard(article: articles[index]); }, ), ), loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => ErrorView(message: err.toString()), );这个拆分是真的实用,上线后愿意把列表页做完善的人和只跑通 Demo 的人,"页面的皮实程度"一眼就能看出来。
6. 图片、缓存、还有那个"下拉刷新"的隐藏坑
6.1 图片缓存:不要手写,但要了解原理
信息流页面最容易卡的地方就是图片。服务器返回的封面图如果不做缓存,每次滚出视野再滚回来就要重新下载,基本等于灾难。这次我用cached_network_image,一行代码接入:
CachedNetworkImage( imageUrl: article.coverUrl, fit: BoxFit.cover, placeholder: (context, url) => const ShimmerBox(), errorWidget: (context, url, error) => const Icon(Icons.broken_image), )它内部会缓存到内存和磁盘,滚动回访不再重新请求网络。有一点必须提醒:图片占位图不要用默认的灰色方块,我只是写了一个极简的ShimmerBox(一个带浅色背景的 Container),就能让首屏加载的观感提升一个档次。一个背景色循环动画的封装,半天就能写好,收益很值。
6.2 列表详情页的本地缓存策略
我这次给详情页做了一个"先展示缓存、再静默更新"的策略。具体实现:
- 进入详情页时,先用文章的 id 去本地缓存找历史记录(包括标题、内容、时间)
- 如果有缓存,立即展示缓存内容
- 同时发起网络请求,拿到最新数据后刷新页面并更新缓存
- 如果当前断网,就停在缓存页面,并提示"当前显示的是离线缓存"
这个策略的代码核心非常简单,但能实打实提升用户弱网体验。我用shared_preferences存缓存 JSON,因为它是异步的、开销小,详情页数据这种单条对象用键值存储足够了。
class DetailCacheService { static const String _keyPrefix = 'detail_'; final SharedPreferences _prefs; Future<void> save(String articleId, ArticleModel article) async { await _prefs.setString(_keyPrefix + articleId, jsonEncode(article.toJson())); } ArticleModel? read(String articleId) { final raw = _prefs.getString(_keyPrefix + articleId); if (raw == null) return null; return ArticleModel.fromJson(jsonDecode(raw) as Map<String, dynamic>); } }如果换更大的项目,建议改成数据库或文件存储,一两个页面用 prefs 清爽够用。
6.3 下拉刷新的一个隐藏坑
Flutter 的RefreshIndicator默认嵌套在ListView里很顺畅,但有一个常见 bug:当列表内容不满一屏时,手势下拉经常触发不了刷新。原因是ListView默认只要内容不到一屏,就没有足够的滚动空间,下拉手势会被识别成普通滑动。
解决办法是给ListView加一个AlwaysScrollableScrollPhysics():
ListView.builder( physics: const AlwaysScrollableScrollPhysics(), itemCount: articles.length + 1, ... )这个小改动如果你不提前知道,真机调试时会觉得"明明写了 RefreshIndicator 为什么拉不动",特别浪费时间。
7. 详情页、路由参数和返回刷新:平时最容易忽略的联动逻辑
7.1 详情页布局与生命周期
详情页的布局相对简单:顶部标题、作者和时间、封面大图、正文文本。但有一个业务细节值得注意:详情页可能会从列表页进入,也可能从"收藏列表""搜索页"进入。不管从哪进,详情页都需要知道自己展示的是哪篇文章。我在前面提到过用一个DetailArgument打包参数:
class DetailArgument { final String articleId; final String title; final String coverUrl; const DetailArgument({ required this.articleId, this.title = '', this.coverUrl = '', }); }跳转时:
Navigator.pushNamed(context, '/detail', arguments: DetailArgument( articleId: article.id, title: article.title, coverUrl: article.coverUrl, ));详情页拿到articleId之后,先装饰性地展示传入的标题和封面(让用户感觉页面秒开),再实际请求正文内容,请求回来后再替换成完整数据。这比"进入详情页转圈等接口"的体验好太多。
7.2 详情页返回列表页时的状态保留
有时候用户从列表点进详情,在详情页喜欢了某篇文章,回到列表时希望能反映这个状态。我这次把"喜欢"状态放在 Riverpod 的全局 Provider 里,而不是 DetailPage 的局部 State。这样详情页更新了喜欢状态,列表项通过监听同一个 Provider 自动刷新。
final favoriteIdsProvider = StateProvider<Set<String>>((ref) => <String>{});列表页的 ArticleCard 用ref.watch(favoriteIdsProvider.select((v) => v.contains(article.id)))来决定红心是否点亮。返回列表时不用手动刷新任何东西,状态天然同步。这是状态管理分离 UI 和逻辑之后最爽的一点。
8. 调试技巧、打包配置、以及我在这个项目里最想分享的三句话
8.1 日志与调试:Flutter DevTools 是神器
联网应用调试时最刚需的是看请求参数和响应数据。dio 自带的LogInterceptor在 debug 模式开起来就够了:
if (!kReleaseMode) { _dio.interceptors.add(LogInterceptor(responseBody: true)); }真机调试时我最常用的工具是 Flutter DevTools 里的 Network 面板和 Inspector。前者能看请求耗时和数据体,后者能实时查看 Widget 树,排查布局溢出非常直观。开发时发现渲染问题,先打开 Inspector 看父级约束,一大半问题当场就能定位。
8.2 Android 打包的配置要点
先用flutter build apk --release打出安装包前,有几步别漏:确认ApplicationId改成自己的包名;在AndroidManifest.xml里给发布版本加好网络权限(默认 debug 有,release 可能没开);如果用了网络明文地址,Android 9 以上系统默认禁止明文 HTTP,需要配置usesCleartextTraffic="true"或者做网络安全配置。
一个小细节:发布前把android/app/build.gradle里的minifyEnabled保持默认 false。Flutter 的 release 构建体积本来不小,开启混淆并不能像原生那样显著瘦身,反而可能引入 runtime 问题,新手期没必要碰。
8.3 iOS 与 macOS 环境的注意点
如果你主要在 Windows 上开发,最后需要一台 macOS 来打包 iOS 版本。这点我在项目开始就提醒了:Flutter 项目跨端能力确实好,但 Apple 平台的构建环节绕不开 macOS。提前确认好公司/团队有没有 Mac,再决定最终交付哪几个平台。也可以在项目早期就把 iOS 目录生成好,避免后面想发 iOS 还得回来补配置。
提示:我在实战中踩过一个特别无脑的坑——用
flutter create .时没有先生成 iOS 目录,结果真正要发 iOS 时补生成,导致丢了一些平台特有配置。项目创建时就把所有目标平台的目录一次性生成,之后不想要可以删,但别等要的时候再补。
8.4 三句实操心得
第一,项目实战的进度不取决于你会多少,而取决于你能不能把一个功能从网络走到界面完整跑通。比起反复研究十几个包,不如把一两条数据流彻底打通。
第二,处理异常时的体验,决定了别人愿不愿意持续用你的 App。网络错误页面不能只显示一串英文异常,至少提供"重试"按钮;加载失败时要保留上次数据,而不是直接白屏。这套逻辑在初期就要写进列表页模板里,后面每个页面都复用。
第三,从一开始就给工程留一层"替换的余地"。数据源的替换、接口的替换、ui 主题的替换,分别隔离到 service、provider、theme 三层里。这个项目未来不管是接入真实后端、改成别的题材,还是派生出新 App,都会变得非常轻松。
9. 关于这个项目的后续扩展:信息流只是骨架
到这里,第一个实战项目的核心流程已经完整走通了:启动页加载、首页列表、网络异常处理、详情页缓存、下拉刷新、返回联动状态、打包发布的前置配置。说实话,这个骨架的复用性比我做之前预想的还要高。
我真切建议你把这份代码跑起来之后,试着做三个方向的扩展:把数据源从模拟接口换成本地 JSON 或真实后端,给列表加上分类 tab,加一个简单的搜索页。每一件事都不会动摇已经搭好的架构——这就是"分模块"带来的底气。
如果你碰到与文章里不同的报错或现象,大概率出在环境版本上。优先确认三样东西:Flutter SDK 版本、dio 版本、Riverpod 版本。版本组合不同,API 细节会有差异,但整体思路完全一致。下次实战,我准备把登录流程和 token 刷新、本地数据库、通知推送这几个更贴近商业项目的模块拆开讲,到时候再继续。