1. 项目背景与整体设计思路
1.1 为什么用 Flutter 做二手交易平台
这个项目的起点其实很朴素:我手头的安卓和 iOS 工程师都不够用,但产品又要求必须快速覆盖主流移动端,甚至还要为鸿蒙这类新系统留好入口。二手物品交易这个场景和普通内容社区不太一样,它牵扯信息流、图片、IM 聊天、订单、支付和评价,业务链路长,页面状态多,要是两套原生代码各写一遍,光是联调支付和权限就得拖掉一个月。所以我直接把技术路线锁定在 Flutter 上,目标就是一套代码跑通安卓、iOS,以及后续的鸿蒙端。
Flutter 相比其他跨平台方案有几个点非常适合交易类应用。第一,它的自绘渲染引擎保证了两端 UI 高度一致,商品图、价格标签、成色角标这些视觉元素在安卓和 iOS 上不会出现偏差,这对交易信任感很重要。第二,Dart 的 AOT 编译让列表滑动和图片加载这类高频操作不会掉帧,二手应用的信息流和聊天页恰恰是滚动手势最密集的地方。第三,Flutter 的 Widget 树和状态管理模型天生适合处理复杂的页面状态。比如商品详情页的收藏、关注、立即购买按钮,在不同登录态和商品上下架状态下有完全不同的展示逻辑,用响应式状态驱动比命令式写一堆 if 要清晰得多。
选型时我也认真对比过 React Native 和 UniApp。React Native 的桥接层在聊天这种高频消息场景里容易产生通信开销,而且第三方原生库跨端对齐成本很高。UniApp 上手快、插件多,但遇到像自定义相机、图片裁剪、WebSocket 长连接这类偏原生能力时,绕不过去就得自己写混合插件,调试起来反而更折腾。二手交易应用的核心竞争力在于体验的顺畅和交易链路的严谨,Flutter 在这两方面的整体把控是最稳的。如果你也是几个人小团队要做跨端业务应用,Flutter 是值得优先考虑的方向。
1.2 鸿蒙适配的技术路径
做这个项目时,鸿蒙适配已经不是一个可选项,而是必须考虑的落点。二手交易用户群里,很多人会提前把旧手机拿出来做"以旧换新",新系统设备的占比上升得很快,不及时支持就意味着丢一批真实卖家。 Flutter 跑鸿蒙的技术路径其实比想象中成熟:OpenHarmony 社区维护了一套 flutter_flutter 仓库,支持构建 HAP 包,代码层面几乎可以复用同一套 Dart 逻辑,只是打包和运行环境不同。
具体流程上,先安装 DevEco Studio,配置好鸿蒙 SDK,然后在 Flutter 工程里切换到 ohos 目标。第一次构建时 SDK 会自动生成 ohos 目录,之后用flutter build hap就能产出鸿蒙安装包。需要说明的是,鸿蒙插件生态和 Android 不完全一致,纯 Dart 实现的库基本没问题,但涉及原生能力的第三方插件,比如相册选择、定位、推送,就要检查有没有对应的 ohos 实现。我在项目里做了个排查动作:把所有用到的插件过一遍,发现 image_picker 在鸿蒙上有替代方案,而 dio、flutter_screenutil 这类纯 Dart 库则完全不需要担心。这个适配工作在排期上建议放到整体开发的后半段,等核心业务代码稳定了再做环境切换,避免两边来回改。
1.3 功能边界与 MVP 取舍
二手交易平台如果照搬闲鱼的完整功能,一个小团队半年都做不完。我做的第一件事就是砍需求。直播、竞拍、信用分这些高阶玩法全部延后,支付也先按聚合支付方案接最基础的渠道,不做平台担保账户的复杂设计。最终 MVP 保留了用户登录与实名认证、商品发布、商品列表与搜索、商品详情、IM 聊天、订单交易、评价这几个模块,目标是让"买家找到商品、和卖家沟通、下单付款、确认收货、互相评价"这条闭环完整走通。
砍需求不是拍脑袋。我把每个功能都按"是否影响交易闭环"和"是否影响核心体验"两条标准打分,分数低的直接排到二期。比如直播卖货虽然热闹,但它需要独立的推拉流服务和大量运营配合,风险高收益不确定;而 IM 聊天虽然是二手交易里最容易被低估的模块,但买卖双方如果没法实时沟通,交易成功率会直线下降,所以必须放进首版。另外成色描述、实物拍摄、同城自提这类细节功能反而很重要,它们直接影响买家对商品真实性的判断。把边界划清楚之后,整个开发排期就变得可控了,这可能是这个项目最值得复盘的部分。
2. 技术选型与工程架构
2.1 状态管理选型:我为什么用 Riverpod
项目里涉及的状态非常多,发布页的表单状态、列表页的筛选条件、聊天页的会话未读、订单页的流程状态,如果全部用 setState 或者简单 InheritedWidget 来管理,页面多了以后必然乱成一团。我一开始在 Bloc 和 Riverpod 之间纠结过,最后选了 Riverpod。理由是它在编译期就能发现 provider 引用错误,而且不依赖 BuildContext,写单元测试时非常省事。Bloc 的样板代码太多,事件、状态、bloc 三个文件来回倒,对快速迭代的 MVP 阶段来说负担偏重。
Riverpod 的组合方式也很直观。把接口请求包成 FutureProvider,页面只需要 watch 它,数据加载、失败、重试都变成声明式描述,不需要手写加载状态机。筛选条件变化时只需要更新对应的 StateProvider,列表数据会自动重新请求。它还有个 autoDispose 修饰符,页面销毁后自动释放资源,避免聊天页这种高频进出场景堆积无用状态。
final productFiltersProvider = StateProvider<Map<String, dynamic>>((ref) => {}); final productListProvider = FutureProvider.autoDispose<List<Product>>((ref) async { final filters = ref.watch(productFiltersProvider).value ?? {}; final api = ref.watch(apiClientProvider); return api.fetchProducts(filters); }); class ProductListPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final products = ref.watch(productListProvider); return products.when( data: (list) => ListView.builder( itemCount: list.length, itemBuilder: (context, index) => ProductCard(product: list[index]), ), loading: () => const Center(child: CircularProgressIndicator()), error: (e, st) => ErrorRetryView( onRetry: () => ref.invalidate(productListProvider), ), ); } }这个例子基本覆盖了列表页的核心逻辑,筛选状态和网络请求解耦,页面只需要关心 UI。给同样在选型的朋友一个建议:状态管理没有银弹,选一个团队理解成本低的,比选一个理论上更强的更重要。
2.2 网络层与本地持久化方案
网络层我用了 Dio 加 Retrofit 注解生成。Dio 的拦截器机制可以统一处理 token 注入、日志打印、错误码映射,Retrofit 则把接口定义收敛成抽象的 Repository 接口,前端页面根本不直接接触 HTTP 细节。我在拦截器里做了一个关键处理:当接口返回 401 时,不弹错误提示,而是先尝试使用 refreshToken 静默续期,续期失败再跳转登录页。这个策略对交易应用特别重要,用户在浏览商品时如果突然被踢下线,购物决策就断了。
class AuthInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestHandler handler) { final token = TokenStore.instance.token; options.headers['Authorization'] = 'Bearer $token'; handler.next(options); } @override void onError(DioException err, ErrorInterceptorHandler handler) { if (err.response?.statusCode == 401) { AuthController.instance.silentRefreshOrLogout(); } handler.next(err); } }本地持久化用了两套方案。token、用户信息这类轻量数据用 shared_preferences,简单可靠;首页商品流的数据缓存用 hive,因为它的读取速度快,能把上一版浏览的历史商品快速恢复出来,给用户一种"还在原来位置"的连贯体验。图片缓存则交给 cached_network_image,它自带内存和磁盘两级缓存,列表页快速滑动时不会反复发网络请求。
2.3 工程目录与代码分层
项目目录我采用的是 feature-first 结构,而不是传统的 controller/service/model 三层。每个业务模块独立成一个文件夹,模块内部再拆分数据层、状态层和页面层。这样做的好处是新增一个功能时基本不动其他模块的代码,比如二期加直播入口,只需要在 features 目录下新增 live 文件夹,其他模块不受影响。
lib/ ├── app/ # 应用入口、路由表、全局配置 ├── core/ # 网络客户端、工具类、常量 ├── features/ # 业务模块 │ ├── auth/ # 登录注册、实名认证 │ ├── product/ # 商品发布、列表、详情 │ ├── chat/ # 会话列表、聊天页 │ └── order/ # 订单列表、订单详情、售后 ├── shared/ # 通用组件、Widget 封装 └── main.dart路由这块我用的是 go_router,而不是简单的 Navigator.push。交易应用经常遇到"买家下单成功后要回到商品列表并刷新"这种跨层级跳转,go_router 的命名路由和重定向能力可以很好地处理这些场景。另外我把所有页面统一做成深色状态栏配合白色背景,避免不同机型之间出现页面底部被系统导航条遮挡的问题。
3. 核心业务模块的实现细节
3.1 用户登录与实名认证
登录模块首版只做了手机号验证码登录,没有做密码登录。为什么?因为大部分二手交易用户的使用频次不高,他们不愿意记密码,验证码登录的成本最低。为了防刷,我在客户端做了三个动作:按钮 60 秒倒计时、接口加入设备指纹、连续请求三次后强制弹出图形验证码。这三个动作叠加后,基本能挡住脚本批量刷验证码的情况。
实名认证是二手交易平台的重灾区,身份证号不核验,骗子就会批量注册小号。首版我把认证流程设计成"人工审核 + 数据接口核验"两级。用户上传身份证照片并填写姓名证件号后,客户端先调身份证 OCR 服务自动识别,再走人工抽查。等二期接入人脸识别,再把审核流程全自动化。做这个功能时踩过一个坑:身份证照片如果压缩太狠,OCR 识别率会明显下降,所以实名认证的图片我单独放了原图上传通道,不做压缩处理。
3.2 商品发布:多图上传与表单校验
商品发布是整个应用里交互最重的一个页面。用户要填标题、描述、品类、成色、价格、原价、交易方式,还要传最多九张图。为了让用户不放弃发布,我把这个页面拆成了三步:先传图选品类,再填标题和描述,最后填价格和交易方式。分步的好处是每一步的信息量都小,用户不容易产生畏难情绪。
图片上传这里我用了 FlutterImageCompress 做本地压缩。实测下来,一张 3MB 的照片按质量 75、宽高不超过 1280 压缩,体积能降到 200KB 左右,上传速度快很多,清晰度在手机端也够看。注意九张图不能循环用同一个压缩方法就完事,还得按顺序管理上传进度,我在上传队列里用并发 3 的方式控制峰值流量,并显示每个文件的独立进度条。
Future<void> compressAndUpload(File file, int index) async { final compressed = await FlutterImageCompress.compressAndGetFile( file.path, '${file.path}_$index.jpg', quality: 75, minWidth: 1280, minHeight: 1280, ); final url = await uploadApi.upload(compressed); imageUrls.add(url); }表单校验规则也是重点。标题要过滤掉全部为空格或换行的情况,价格限制在 0.01 到 100000 之间,原价必须大于等于售价,否则买家一眼就能看出虚标。成色我用了一个固定枚举加说明文字:全新、几乎全新、轻微使用痕迹、明显使用痕迹、有破损瑕疵。模糊的文字描述容易引起售后纠纷,成色定义越明确,后续扯皮越少。
3.3 商品列表:搜索、筛选与推荐排序
商品列表是交易平台的流量入口,我花了不少心思在它的查询效率和展示体验上。首版没有上 Elasticsearch,而是用数据库的模糊查询加索引来过渡。具体做法是给商品表建 title 和 category 的组合索引,搜索时按标题、描述、品类三个字段匹配。用户超过一定量级之后,再考虑把搜索服务独立出来。数据分页采用了基于 cursor 的方式,和传统的 page 分页相比,它不会因为中间插入了新数据导致下一页跳号,信息流体验更连贯。
列表的筛选条件包含品类、成色、价格区间、所在地和排序方式。排序方式有最新发布、价格从低到高、价格从高到低三个选项,我的实现方式是把排序和筛选参数放到同一个 provider 里统一管理,任何一项变化都会触发列表重新请求。为了避免用户频繁拖动筛选面板导致网络请求风暴,我对筛选按钮做了 300ms 的防抖,只有停止操作后才发起新请求。
列表项的设计上,封面图占左边固定宽度约 100,右侧展示标题、成色标签、价格和所在地。封面图统一使用服务端的图片处理参数,指定宽 200、质量 70,这样列表页的流量消耗能减少一半以上。滑动性能方面,ListView.builder 搭配 const 构造函数,尽量复用 Widget,实测在普通安卓机上一帧都不掉。
3.4 即时聊天与会话列表
聊天是二手交易成交的关键环节,也是一开始最容易被低估的模块。我采用的是 WebSocket 长连接方案,服务器在生产环境用的是基于消息队列的推送网关,客户端这边只需要维护好连接状态和消息投递。连接管理我做了四件事:半小时内的自动重连、15 秒心跳保活、断网检测恢复后主动重连、消息发送失败后的重发队列。
class ChatSocket { static const _heartbeat = Duration(seconds: 15); Timer? _timer; WebSocketChannel? _channel; void connect(String token) { _channel = WebSocketChannel.connect( Uri.parse('wss://api.example.com/chat?token=$token'), ); _timer = Timer.periodic(_heartbeat, (_) { _channel?.sink.add('{"type":"ping"}'); }); } }聊天消息最怕的是乱序和重复。乱序问题的根因是网络重传导致同一个 seq 的包到达顺序不一致,我这边统一按服务端下发的 messageId 升序展示,客户端不做乐观调整。重复问题的处理是本地维护一个已接收 messageId 的缓存集合,重复包直接丢弃。聊天内容上,首版支持文本、图片和商品卡片三种消息。商品卡片点击后能直接跳转商品详情,这是促成交易转化的重要入口。买家看完详情回到聊天,继续和卖家沟通报价,整个闭环就串起来了。
4. 交易链路与订单状态机
4.1 下单与支付流程
二手交易的下单和标准电商不太一样,买家经常要和卖家先聊几句,确认商品还在、成色真实,然后才发起购买。所以我把下单入口做了两个:商品详情页直接下单,聊天页里通过商品卡片下单。两种入口殊途同归,最终都走同一个下单接口。
下单时需要特别注意"商品快照"这个概念。买家加入订单时,商品可能已经下架、改价或被人买走,所以订单里必须把商品标题、图片、价格、成色这些关键信息复制一份保存,而不是直接关联商品表。否则三个月后买家查看历史订单,看到的可能是卖家改了标题和价格之后的新商品信息,这就容易引发纠纷。支付环节按业务需要接入了聚合支付 SDK,覆盖微信、支付宝和一些银行渠道,鸿蒙端的支付则根据目标用户群体做了对应渠道的接入判断。
4.2 订单状态迁移的设计
订单状态是整个交易系统的核心,我在设计时用枚举把状态固定下来,并明确每个状态允许的操作和对应的迁移路径。状态机的好处是避免代码里出现一堆 if else 判断用户当前能不能取消、能不能发货、能不能退款。所有状态变化都通过统一的服务端接口触发,客户端只能发起操作,不能自行修改状态。
| 当前状态 | 允许操作 | 下一状态 |
|---|---|---|
| 待支付 | 用户取消、超时关闭 | 已取消、已关闭 |
| 待发货 | 卖家发货 | 待收货 |
| 待收货 | 买家确认收货、超时自动确认 | 已完成 |
| 已完成 | 买家发起售后 | 退款中 |
| 退款中 | 卖家同意、平台仲裁 | 已退款、已关闭 |
两个超时逻辑值得注意:待支付订单超过 30 分钟未支付自动关闭,避免占用库存和卖家精力;买家收到货后 15 天未确认收货,系统自动确认并放款给卖家。这两个逻辑虽然简单,但能省掉大量客服沟通成本,我强烈建议首版就做上去。
4.3 评价与售后流程
评价体系是二手交易平台信任建设的一部分。首版做了文字加图片评价,并有追评功能。买家确认收货后 7 天内可以评价,超过 7 天入口关闭,评价完成后双方可见。图片评价的图片同样走商品发布的压缩上传通道,不再重复造轮子。
售后流程我做了简化版:买家在已完成订单里发起售后申请,填写原因和诉求,卖家在 48 小时内处理,超时自动同意退货。买卖双方达不成一致时,可以申请平台介入,由后台人工审核。首版为了不把流程拖得太复杂,只做了退款和退货退款两种售后类型,没有做换货。实际上二手商品做换货的物流和成色判定成本都很高,不做反而是明智的。
5. 鸿蒙平台适配与性能调优
5.1 Flutter 鸿蒙运行环境搭建
鸿蒙端适配的第一步是把环境搭好。我用 DevEco Studio 作为 IDE,先在工具链里配置好鸿蒙 SDK,然后在 Flutter 工程里切换到 ohos 目标平台。工程第一次构建时,会自动生成 ohos 目录,结构上类似安卓的 android 目录,包含 Entry 模块和资源文件。之后通过flutter build hap就能产出可安装的 HAP 包,用 hdc 命令或者 DevEco Studio 直接装到真机上。
这套流程有几个容易踩的坑。第一,Flutter SDK 版本和鸿蒙 SDK 版本要匹配,社区维护的 flutter_flutter 仓库对版本有明确要求,版本不匹配时编译会报错,最常见的就是那句 the current configured Flutter SDK is not known to be fully supported。第二次遇到这个问题后,我把 Flutter SDK 固定到推荐版本,再也不跟着最新版跑了。第二,签名证书要先配置好,真机安装和上架都需要,否则会提示签名校验失败。第三,鸿蒙端构建时注意 CPU 架构,armeabi-v7a、arm64-v8a、x86_64 都要在 abiFilters 里配置清楚,方便模拟器调试。
5.2 在鸿蒙端做过的独有适配
鸿蒙系统在 UI 规范和权限模型上和安卓有差异,适配工作集中在几个点上。首先是顶部状态栏和底部安全区。不同鸿蒙设备的刘海屏、挖孔屏、悬浮球位置都不一样,我用 MediaQuery.padding 统一处理安全区域,避免页面上滑时内容被系统元素遮挡。其次是返回手势。鸿蒙系统的侧滑返回手势和 Flutter 的页面路由之间偶尔有冲突,表现为返回时页面残留半屏,解决方案是在路由层统一接管返回逻辑,用 PopScope 拦截并恢复正常手势。
权限处理是另一个容易出问题的点。鸿蒙的相机、相册、位置权限申请和安卓的运行时权限机制不完全一样,需要检查项目在鸿蒙上的权限声明文件。比如选择相册图片,在安卓端只需要 READ_EXTERNAL_STORAGE,在鸿蒙端要按 Media Library 的类型逐一申请。输入法遮挡问题在鸿蒙上也出现过,聊天页输入框会偶发性地弹到键盘下面,我的解决办法是 Scaffold 固定 resizeToAvoidBottomInset 为 true,并在输入框聚焦后做一次 ensureVisible 滚动。
性能调优方面,我主要用 Flutter DevTools 的 Profile 模式看帧渲染耗时。首版上线后遇到两个典型问题:商品列表快速滑动时图片占位闪烁,以及聊天页滚动卡顿。前者的解决方案是调整图片缓存策略,让占位图缓存内存级别高一些;后者的根因是消息气泡组件里有大量文本样式计算,我把每条消息的文本 style 做了常量化和 const 构造,卡顿明显缓解。
6. 常见问题与排查技巧实录
6.1 编译期到构建期的典型问题
编译问题集中在三个场景:Flutter SDK 版本不支持、插件兼容性、构建产物打包失败。SDK 版本问题我在前面提过,网上大量讨论里也反复出现,解决办法就是统一版本、锁定版本,不要在项目中途随意升级。插件问题主要发生在有原生代码的库上,排查时用flutter pub deps把依赖树拉出来,逐个检查是否提供 ohos 支持,遇到不支持的先找替代方案或者用条件编译隔离。
构建产物打包失败的情况比较杂。常见的是 ohos 目录下的资源引用错误,比如启动图没有放在默认位置、图标尺寸不对等。还有一种情况是构建机磁盘空间或内存不足,HAP 打包时 Gradle 任务会直接 OOM。我这边做了个经验总结:构建机上预留至少 10GB 可用空间,并把 Gradle 最大堆内存调到 4GB,基本不会再遇到打包中断。
6.2 运行期与 UI 问题排查表
运行期问题往往比编译问题更隐蔽。有些问题只在真机上出现,模拟器完全复现不了。我整理了一个排查速查表,遇到对应现象可以直接对照。
| 现象 | 根因方向 | 排查建议 |
|---|---|---|
| 打开商品页黑屏 | 启动图配置或首帧耗时过长 | 检查 ohos 启动图资源与异步初始化顺序 |
| 图片加载不出来 | 证书校验或图片域名未白名单 | 检查网络库证书配置与服务端 HTTPS 证书链 |
| 聊天消息偶发乱序 | 消息推送时序问题 | 按 messageId 排序,检查 seq 是否单调递增 |
| 输入框被键盘遮挡 | 系统输入法高度适配问题 | 固定 resizeToAvoidBottomInset,聚焦后 ensureVisible |
| 列表滚动掉帧 | 图片解码或 Widget 重建开销 | Profile 模式查看帧耗时,检查 item 内 const 构造 |
6.3 性能优化与体验打磨心得
最后分享几个在后期打磨阶段觉得特别有用的心得。第一,商品列表用分页加载后,用户滑到第 100 条商品时,前 50 条已经不在屏幕内,可以考虑用 ListView.builder 的缓存区控制,避免一次性渲染太多 item。Flutter 默认的 cacheExtent 已经够用,但如果你在 item 里放了比较重的组件,可以手动把它的值调小一些。第二,图片请求务必加上宽高参数,让服务端返回合适的缩略图。我后来在图片处理组件里统一封装了一个"常规列表图"和"详情大图"两档尺寸,流量和内存都降下来了。
第三,聊天页的消息列表要做增量渲染。我首版是全量渲染,消息多了以后明显变卡。改成按需渲染并配合 ScrollController 定位后,会话和消息页都流畅很多。第四,发布页的图片压缩过程是耗时操作,我把它放到了 isolate 里执行,避免压缩大图时 UI 线程卡顿。这些优化单项看起来都不起眼,但合在一起对体验的提升非常明显。
做了这个项目之后我最大的体会是:跨平台开发的价值不在于消灭原生,而在于把业务逻辑沉淀成可复用的资产。整个二手物品交易助手的业务代码,从安卓、iOS 到鸿蒙端,一行没改就完成了场景覆盖。如果你也在做类似的应用,建议先把交易闭环和状态机想清楚,再着手写页面。基础打牢,后面不管加直播还是加信用分,都只是往稳定的骨架上填肉而已。