校园二手交易这个事情每年毕业季都会有大量需求爆发宿舍楼里堆满了旧书、台灯、自行车新生入学又恰恰需要这些低价物资。做一款校园二手交易平台并不新鲜但真正落地到开发尤其是前端这一层和普通电商 App 有太多不一样的地方。这个项目从立项到第一版完成前后两个多月用的技术栈是 Flutter。整个复盘走下来我最深的感受是Flutter 在工具型、双端快速覆盖的场景下效率确实很高但“做完”和“做好”之间隔着大量看不到的工程细节。这篇复盘不会写成一份标准教程而是沿着真实开发路径拆开讲需求怎么拆解、技术怎么选型、工程怎么从 0 搭建、核心功能怎么实现、性能优化从哪下手、打包发布时有哪些坑。我会把做对的决策和做过再后悔的选择都放进来适合正准备用 Flutter 做跨端应用、或者想了解一个校园项目如何从想法落地的开发者参考。1. 先想清楚再动手需求拆解与选型复盘1.1 校园二手场景到底在解决什么问题做前端第一件事不是写代码而是把“校园二手交易”这七个字翻译成具体功能。二手交易通用功能很多但校园场景有自己鲜明的特点。第一是商品天然低客单价、高频次、强季节性。毕业生卖的是旧书、台灯、收纳箱、自行车买家是刚入学的学生。这些东西利润不高但流转速度快。商品描述不需要多精致反而越直白越好买家更关心价格、成色和距离。第二是信任半径小。同校学生之间交易天然比陌生人信任度高很多人会约在宿舍楼下当面交易。所以前端不需要做特别复杂的担保支付、物流跟踪核心是信息撮合和交流沟通。第三是流量集中爆发。毕业季前后是一个明显的峰值平时可能一天没几个新帖。这要求前端能扛住短时间内的列表浏览压力图片加载策略就显得格外重要。把这些需求翻译成功能清单就是商品浏览与搜索、商品发布、商品详情、私信聊天、简单订单状态、个人中心。再往下拆商品浏览要支持分页加载、筛选、排序发布要支持图片多选、压缩上传、表单校验聊天要做未读、会话列表订单状态要清晰因为校园交易大多数走线下状态机不能设计得太重。还有一类需求容易被忽略就是弱网和离线体验。校园里教学楼、图书馆某些区域信号并不好列表页如果每次都强刷网络体验会非常差。我在项目里把已浏览过的商品做了一层本地缓存至少保证用户再次打开时先看到上次的数据再静默刷新。1.2 为什么最终选了 Flutter一波理性的对比选型阶段我认真对比过四条路线Android / iOS 原生双端、React Native、uni-app、Flutter。原生双端方案质量最稳但需要至少两个人分别维护两套代码。项目工期只有两个多月一个人要覆盖双端原生直接被否掉。React Native 当时团队没有深入接触过且环境配置和依赖管理这些年变化较大踩坑成本不确定。uni-app 目标更多是偏向国内小程序生态如果要上架 App Store原生能力边界处理起来反而别扭。最后 Flutter 胜出核心原因有三个一套代码双端编译、UI 渲染一致性强、热重载让界面调试效率极高。为了不凭印象选我做了一个简单对比表把核心关注点列出来选型维度FlutterReact Native原生双端双端代码复用度高较高低UI 一致性自绘引擎各端表现一致依赖平台控件偶有偏差各自实现开发效率热重载快UI 调试顺畅热更新机制成熟但配置繁琐双倍工作量性能接近原生列表滚动流畅桥接层有性能损耗最优生态成熟度中高工具类需求基本覆盖高但库质量参差平台强相关学习成本需要新学 Dart熟悉 JS 则上手快需分别掌握两套栈这个表并不代表 Flutter 永远最好而是在“单人 双端 短周期 工具型应用”这个组合下它最合适。复盘时回头看如果团队原本就熟悉 React Native那选 RN 也完全可以。技术选型不是追新而是背景匹配。1.3 前端架构规划状态管理、路由与数据层选型定了工程内部还是要再定三个规矩状态管理、路由、数据层。状态管理我最终选了 Riverpod而不是 GetX 或 Bloc。理由很实际GetX 写起来最快但把路由、状态、依赖注入全混在一个全局的东西里项目一大就很容易出现“找不到状态在哪改”的情况。Bloc 结构严谨但模板代码偏多写校园项目这种体量显得有些重。Riverpod 编译期安全、依赖注入清晰还可以很方便地用AsyncNotifier管理异步状态比较匹配团队的前端习惯。路由用的是 go_router。刚开始我考虑过官方 Navigator但做到页面深链、登录守卫、动态参数传递时go_router 声明式配置明显更省心。比如商品详情页、用户主页这类路径直接在路由表里定义path: /product/:id页面通过参数读详情逻辑干净很多。数据层我做了三层拆分Model负责数据实体ApiClient负责 HTTP 请求Repository负责把接口返回转换成 UI 可用的模型。UI 层不直接操作 Dio只面向 Repository。这样做的好处是接口字段变了只需要改模型和 Repository页面不用跟着动。当时的目录结构长这样lib/ core/ # 常量、主题、网络层、工具类 models/ # 商品、订单、消息的数据模型 pages/ # 首页、发布、详情、消息、个人中心 providers/ # Riverpod 状态管理 repository/ # 接口定义与数据处理 widgets/ # 通用组件这套目录不是一个晚上想出来的而是先按页面堆代码堆到第十天左右发现互相引用越来越乱才花了一个下午重构。复盘下来的经验是目录结构这种事最好开工第一天就定好后面临时改的成本远高于你想象。2. 从 0 搭工程目录、主题和网络层是最先要定的规矩2.1 工程初始化与目录结构一开始乱后面全乱Flutter 工程创建本身很简单flutter create一条命令就能跑起来。但这个阶段有两个细节很影响后续开发。第一个是包名。Android 包名和 iOS Bundle ID 最好在项目创建时就确认好后面再改牵扯到签名、配置、推送代价非常大。我用的是按团队域名倒序加上项目代号的方式比如com.example.campusmarket虽然以后真上线会换正式域名但至少双端一致不会出现 Android 和 iOS 包名对不上的尴尬。第二个是环境区分。同一套代码要对接开发环境、测试环境、生产环境不能靠手动改常量。我采用的是--dart-define传入环境变量代码里这样读取class AppEnv { static const String apiBase String.fromEnvironment( API_BASE, defaultValue: https://dev-api.example.com, ); static const bool enableLog bool.fromEnvironment( ENABLE_LOG, defaultValue: true, ); }这样打测试包传--dart-defineAPI_BASEhttps://test-api.example.com打生产包再传生产地址代码里不需要改任何东西也不用担心上线时忘记切环境。依赖管理方面我建议锁版本。pubspec.yaml里不用^而不是每次都升到最新因为 Flutter 生态更新很快一个大版本升级经常带来连锁的 breaking change。锁版本至少保证团队每个人本地跑的依赖是一致的。2.2 主题与通用组件让整款 App 看起来是“一套”的很多校园项目前期恨不得把所有页面都写成一次性页面功能通过了就完事。这样做到后期 UI 一定高度不统一按钮边角有的大有的小、颜色深浅不一致、弹窗风格各有想法。我在项目里提前把主题抽了出来统一放在core/theme里。颜色用 Material 3 的ColorScheme定义主色、辅助色、错误色、背景色字体用统一的TextTheme标题、正文、辅助文字的字号和字重都固定。这样写页面时不需要每次重新定一套风格直接调用主题 token 即可。通用组件也提前沉淀了几个PrimaryButton、TextFieldWrapper、GoodsCard、EmptyPlaceholder、LoadingIndicator。当时做的时候觉得多此一举写下来发现节省的时间非常明显。尤其是GoodsCard首页、搜索结果、个人主页“我发布的”三个地方都要用如果三个页面各写一套卡片光是样式调整就能让人崩溃。间距和圆角也定了规范间距以 4 的倍数递增卡片圆角 12dp阴影用统一的 BoxShadow 参数。视觉上是不是好看先不说至少看起来像同一个团队做出来的产品。2.3 网络层封装环境切换、拦截器和错误码统一校园项目有一个共同的痛后端接口文档经常滞后字段名称一会儿下划线一会儿驼峰而且不同接口的错误码还不太统一。如果页面直接写 Dio 请求后端起一次接口前端就要跟着改一圈。所以网络层在项目第二天就必须立好规矩。Dio 的封装我做了四件事统一超时、全局拦截器、统一错误处理、响应数据直接返回模型。final dio Dio(BaseOptions( baseUrl: AppEnv.apiBase, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), )); dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) { final token localStore.read(token); if (token ! null) { options.headers[Authorization] Bearer $token; } if (AppEnv.enableLog) { debugPrint(请求 ${options.method} ${options.uri}); } handler.next(options); }, onResponse: (response, handler) { handler.next(response); }, onError: (error, handler) { handler.next(error); }, ));这里最关键的一点是不要直接返回 Dio 的Response而是在 Repository 层把数据转换为模型后再交给 UI。举个例子后端返回的字段可能是goods_name我们前端模型叫goodsName如果页面直接操作 JSON改一个字段名就要全局替换。有了 Repository 层只有这一层受影响页面始终拿到的是Product对象。错误处理也要统一。我定义了一个ApiException把 HTTP 异常、业务错误码、网络异常分开。页面层只认业务异常比如“库存不足”“商品已下架”展示对应的文案网络异常和超时统一弹“网络连接失败请检查网络”。提示写拦截器时记得区分 DevLog 和生产 LogDevLog 可以打印请求体和响应体但生产环境一定要关掉防止敏感数据刷屏。3. 核心功能逐个拆商品流、发布、消息与订单3.1 首页商品流下拉刷新、分页加载与筛选首页是一个二手交易平台的门面也是用户停留最久的页面。它看起来只是一个列表实际上要处理刷新、分页、筛选、搜索、空态、错误态并不简单。列表数据我用AsyncNotifierProvider管理。一个商品列表的状态是异步的Riverpod 的异步语法让 loading/data/error 三种状态一目了然。分页的惯用做法是维护_page和_hasMore两个字段上拉触底时_page 1返回数据少于每页数量时_hasMore false停止后续请求。伪代码大概是class ProductListNotifier extends AsyncNotifierListProduct { int _page 0; bool _hasMore true; FutureListProduct build() async { _page 0; _hasMore true; return _fetchPage(); } FutureListProduct _fetchPage() async { final ListProduct list await repository.fetchProducts( page: _page, pageSize: 20, ); if (list.length 20) _hasMore false; return list; } }要注意build方法不要每次都拉旧数据。Riverpod 里ref.invalidateSelf()可以用来做下拉刷新刷新时重新执行build很自然地把第一页拉回来。上拉加载则单独调用_fetchPage把新数据追加进现有列表。搜索功能我做了 500ms 防抖。用户输入关键词不会每次都请求而是等输入暂停后再触发。校园用户搜索的词集中在“自行车”“高数”“台灯”这类短词防抖能有效减少无效请求。筛选条件里我放了价格区间、成色、分类三个维度因为校园商品成色差异对二手价格影响非常大这个筛选在真实使用中点击率很高。列表性能放在后面章节细说但有一个经验先提出来商品卡片高度不要每次动态计算尽量固定高度或者使用itemExtent分页滚动会稳定很多。3.2 发布流程表单校验、图片选择与上传进度发布是二手交易平台最核心的操作也是最能暴露前端细节功力的环节。发布页包含商品标题、描述、分类、价格、成色、联系方式、图片。看起来字段不多但用户耐心有限必须尽量让发布过程顺畅。表单校验我用 Form TextFormField每个字段有独立 validator。价格输入需要禁止非法字符、限制小数两位我在输入框的inputFormatters里限制正则同时验空。成色用下拉选择或者单选标签校园商品成色非常关键我直接用枚举全新、几乎全新、轻微使用痕迹、明显使用痕迹、破损。分类用两级选择数码、书籍、生活用品、运动器材、服饰、其他。图片选择是整个发布流程最容易出问题的地方。我用 image_picker 做选图选择后本地压缩再上传。这里踩坑不少直接传原图一张照片可能 4~8MB学生宿舍 WiFi 上传非常痛苦而且后端处理缩略图也需要额外开销。压缩我用 flutter_image_compress将图片限制在最长边 1280 像素质量 80%压缩后单张基本在 200~400KB 之间。上传前生成缩略图前端自己生成即可不需要后端参与。上传进度用 Dio 的onSendProgress回调显示在图片缩略图上每张图片一个进度状态。这里需要注意并发控制不要一次把所有图片并发上传通常同时最多 3 个任务避免占满带宽导致主图上传迟迟不完成。整个发布按钮的校验我分两层本地表单校验通过后先提交文本字段再上传图片最后把图片 URL 一并提交。如果先传图再提交文本用户取消发布后图片就变成了垃圾数据。这个顺序问题不太会被文档提到但在真实项目里很重要。3.3 消息与订单状态用状态机避免到处 if/else消息模块在第一版其实不需要做得很重毕竟校园交易大多数是发布商品后直接加微信或者留在页面内的简单沟通。但既然是一个交易平台至少需要一个最基础的双向私信能力用户 A 点击商品详情里的“联系卖家”进入和卖家 B 的聊天页。初期聊天我用了轮询而不是 WebSocket。原因很简单后端团队当时还没有 WebSocket 服务轮询可以快速上线。轮询间隔设为 10 秒只在聊天页打开时轮询退出页面就取消。后来的基本思路是在ref里注册一个Timer页面销毁时一定要取消否则就是内存泄漏。订单状态这一块我做得比较轻。校园二手大多当面交易并不需要完整电商的“待付款-待发货-待收货-确认收货”流程。但页面里还是会出现“已预约”“已卖出”“已完成”这样的状态于是前端必须有一个明确的状态机不能在页面代码里散落一堆字符串比较。我用枚举定义状态enum OrderStatus { available, // 在售 reserved, // 已预约 sold, // 已卖出 closed, // 已关闭 }页面根据状态展示不同操作按钮reserved显示“已被预约”sold则不再展示购买入口。状态变化必须通过统一方法处理比如markAsSold()不能一个页面写status 3另一个页面写status OrderStatus.sold。前端状态机的意义就在于保证所有逻辑分支集中在一个地方以后加状态只需要改一处。4. 别只顾功能完整性能优化和体验细节才是分水岭4.1 列表与图片二手交易 80% 的性能问题在这里校园二手平台的首页、搜索结果、个人主页清一色是长列表 商品图片性能优化基本围绕图片和列表展开。图片是二手商品信息最重要的载体但也是性能的最大杀手。第一策略是缓存。图片缓存我直接用 cached_network_image它做了内存和磁盘两级缓存。商品卡片在列表里频繁滑动、上下滚动如果没有缓存每一次滑出屏幕再滑进来都会重新走网络流量和卡顿都在这里。占位图和错误图必须配置否则图片加载慢的时候会出现大段空白非常难看。第二策略是分级图片。列表页只需要展示小尺寸缩略图详情页才需要高清图。如果列表页用了原图用户滚动时内存占用会快速上升到低端机上直接闪退。我当时做的时候后端已经提供了不同尺寸裁剪列表用?imageView2/2/w/400这类参数详情页用w/1200。如果后端不提供裁剪也可以在前端压缩后上传缩略图。这一步不做后面优化成本特别高。第三策略是列表项优化。商品卡片尽量写成const友好的组件避免在 build 中创建新的对象。固定卡片高度让 ListView 的布局计算更高效。缩略图使用cacheWidth参数按需解码避免一张 1080p 的大图只显示在 100x100 的格子里。实测下来优化前后首页滚动帧率从经常掉到 40fps 左右稳定到接近 60fps。不过“优化”不是一上来就做而是在功能可用之后用真机 profile 看了一眼发现图片内存占用高得离谱才下定决心做。4.2 状态管理陷阱我踩过的刷新与内存泄漏坑Riverpod 用好了很顺手但用不好也有不少陷阱这里记录两个真实遇到的问题。第一个是“在事件回调里直接修改状态”。Riverpod 强调不可变状态更新状态应该通过Notifier里面的方法。早期图省事我会在卡片组件里直接ref.read(provider.notifier).updateSomething()这样其实没问题但有一种情况容易出 bug组件 build 中读取了某个状态又在同一个 build 方法里根据它触发异步刷新可能造成循环 invalidate。后来我的约定是build 只负责构建 UI异步动作一律通过用户交互或路由跳转触发避免隐式触发循环刷新。第二个是页面销毁后的异步回调。聊天页轮询、发布页上传进度这些异步任务在页面关闭后仍然可能执行。如果没有判断页面是否销毁就会调用context.mounted相关逻辑时报错甚至出现状态更新发生在已销毁状态上。Riverpod 在 2.x 之后对这种情况有机制但代码里还是要注意在dispose里取消定时器、在异步回调后检查生命周期。另外状态管理不要过度拆分。一个功能一个 Provider 听起来很“规范”但实际调试时会发现状态间有隐藏依赖。一个小技巧把同一个业务域的状态放在同一个 Provider 里比如SellerProvider负责卖家信息、其商品列表、其评价而不是拆成三个独立 Provider。这样后续重构时边界更清晰。4.3 从能跑到好用骨架屏、空态、错误重试一个都不能少“能用”和“好用”之间隔着一层体验细节用户感知最明显的就是加载、空状态和失败状态。加载状态我用骨架屏而不是转圈。商品列表用 shimmer 效果模拟卡片布局用户能预览即将出现的页面结构心理上觉得更快。Flutter 里可以用 shimmer 这个库来实现也可以用 AnimatedContainer 调透明度做简单闪烁。骨架屏对老用户没有太多价值但新用户第一次打开确实体验差异很大。空态是校园项目最容易忽略的地方。搜索没有结果、个人主页没有发布商品、消息列表为空如果只是显示一个空白页面用户会以为页面坏了。我的做法是每个空状态配一个图标 一句说明 一个动作按钮。比如搜索无结果文案是“没有找到相关商品”按钮是“看看热门商品”。消息为空文案是“还没有收到消息”按钮是“去逛逛首页”。错误态也类似网络请求失败不能只是 toast 一声页面需要一个重试按钮并且要保留之前可能已有的数据。Riverpod 的AsyncValue有hasError和value同时存在的情况这时候要在 error 视图里保留旧列表而不是直接清空。还有一个细节收藏和浏览历史要落在本地。学生用户不一定有账号登录就算没登录浏览过的商品、收藏过的商品都应该先存在本地。我用 shared_preferences 或者结合数据库做本地缓存减少对后端的依赖也让 App 在离线状态不至于完全不可用。5. 打包发布、真机适配与最值得记住的避坑记录5.1 几个让我查了半天的真问题整个项目开发中遇到的最典型问题我整理成一个速查表都是真实场景里磨过的问题现象实际原因解决方案图片选择后上传Android 上直接崩targetSdk 高版本访问文件权限不足或图片过大 OOM使用 Photo Picker / FileProvider 方式并且统一压缩后再操作上传 multipart 文件名带中文后端收到乱码编码方式未统一前端构造 MultipartFile 时显式设置 filename 的编码后端也用 UTF-8 解析首页滚动一段时间后掉帧明显图片缓存未生效列表项高度不固定换 cached_network_image商品卡片固定高度缩略图按需解码切后台再回来聊天页轮询还在跑定时器未在页面销毁时取消在 dispose 里取消 Timer同时基于生命周期暂停轮询打包后 App 提示“未知来源”或无法安装签名配置不完整或 targetSdk 与测试机系统不匹配统一签名文件正式包走加固后分发测试包单独配置其中图片上传崩溃那个问题查得最久。当时报错信息不够明显最后定位到是图片选择时返回的原始文件过大上传时内存直接爆掉。解决方式是在图片选择之后立刻做一次压缩而不是在异步上传时才压缩。还有个细节值得单独说Flutter 的调试包和发布包行为不一样编译模式不同导致部分渲染行为有差异。列表卡顿这类问题一定不要在 debug 模式下判断要用 profile 或者 release 模式在真机上测否则会被 debug 模式额外开销误导。5.2 双端打包核对清单每次发布前对着过一遍Flutter 项目双端打包发布时需要核对的内容不太一样我在项目中期开始维护一份清单每次发布都按顺序过一遍省了不少冤枉时间。Android 端的检查项包括应用图标和名称是否符合预期、targetSdkVersion 是否已适配最新系统要求、签名文件是否有效、权限说明文案是否准备好尤其是相册和摄像头权限、混淆规则是否影响第三方库、网络权限是否在 Manifest 中声明。还有一点Android 发布包需要通过加固或至少保证签名可验证否则有些应用市场版本会直接拒绝上架。iOS 端检查项包括Bundle ID 与开发者账号中的描述文件是否匹配、权限描述文案相册、定位如果需要是否写得用户能看懂、上传 App Store Connect 前是否用 TestFlight 做了内测、隐私清单如果涉及图片选择等 API也需要同步适配。iOS 的审核对隐私要求比较严文案含糊很容易被拒绝。实际上双端打包最核心的问题不是“能不能跑”而是“权限文案对不对、签名对不对、环境配置对不对”。这几点全部提前检查打包过程基本一次通过。5.3 回头看的复盘哪三件事该更早做复盘整个项目有三件事如果当初更早做后面会轻松非常多。第一件事是尽早联调真实接口而不是长期依赖 Mock 数据。开始我为了进度页面全部先用假数据写但 3 周后后端真实接口出来发现字段名、分页格式、图片 URL 规则全都不一样返工了将近一周。后来总结了一个教训Mock 数据只用于 UI 布局和交互验证接口字段定义一旦确认立刻把真实接口接入哪怕当时后端延迟高、经常 500也要尽早暴露问题。第二件事是最早把统一主题和框架搭好。前两周我确实边做边探索主题页面风格不统一导致后面改样式成本很高。如果从第一天就按照最终视觉规范来搭至少省出 15% 的开发时间。第三件事是尽快在真机上跑。模拟器和真机在图片加载、权限、性能上的差异非常明显。很多问题在模拟器上完全看不出来部署到真机一下子冒出来。项目第三天就应该在真机上做一次简单冒烟测试让所有权限流程尽早暴露问题。6. 对后来者有用的几条个人建议6.1 控制范围先做一个能用的闭环而不是全功能这是我做校园项目最大的体会。刚开始时总想着把这个平台做成“校园闲鱼”要什么都有举报、信用分、审核后台、在线支付。但两周后发现连最基础的商品发布和浏览还没完全走通。后来硬是砍掉了大半功能只保留发布、浏览、联系卖家、个人中心先保证一个完整的闭环能够跑通。结果这个“减配版”反而在测试阶段实际用起来了用户反馈的优先级才慢慢清晰。校园项目最忌立项即大而全先能跑起来再迭代才是正常节奏。6.2 联调、验收与文档被低估的三件事最后想说三件容易被低估但实际很关键的事。第一是联调一定要坚持“前后端同步定义接口”而不是让前端等后端。哪怕一开始接口简陋也要先定义好 URL、参数、返回结构双方并行做。第二是验收标准要量化。比如列表页首屏加载时间不超过 2 秒、发布图片上传成功率不低于 95%、双端基础流程通过率 100%。没有量化标准的项目后期很容易陷入“感觉差不多”的泥潭。第三是文档不是给公司写的是给两周后的自己写的。我每次部署、切换环境、处理权限问题都会把步骤记在一个本地文档里后面每次发布都能直接照着操作不用重新摸索一遍。这个项目做完之后我发现校园工具类 App 的技术难点并不在于有多复杂的算法或者架构而在于所有细节都能考虑到从网络层、图片缓存、状态管理到打包上架每一步都踏踏实实处理好。Flutter 让这套工作在一个人的节奏下同样跑得动但最终决定一个项目成败的依然是对问题的拆分和复盘的持续跟进。如果你也正在做一个校园或工具类的 Flutter 项目希望这篇复盘能帮你少走一点弯路。