鸿蒙 Next 二手流转 App 开发实战:垂直品类 + 分类筛选 + 联系系统
作者:duluo
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 9500 字
目录
- 引言
- 产品概念与物品模型
- 两 Tab 架构设计
- 分类标签筛选系统
- 物品卡片组件设计
- 详情弹窗与联系流程
- 联系系统与状态管理
- 筛选状态联动机制
- 编译错误全记录
- 第三十五款 App 全景回顾
- 结语
1. 引言
1.1 二手交易与垂直品类
中国二手交易市场规模超过 1 万亿元。闲鱼、转转等综合平台占据了绝大部分份额。但这些综合平台有一个共同的问题:品类太多,用户找到想要的东西需要花时间筛选。
垂直领域二手流转 App 的解决方案是:只做一两个品类。品类越窄,用户越精准。
本 App 聚焦四个垂直品类:母婴、数码、家电、乐器。每个品类 2-4 件物品,共 12 件。用户打开 App 就能看到"我想要的东西"——不需要搜索、不需要筛选、不需要在大量不相关的物品中寻找。
1.2 本 App 的平台属性
本 App 是系列中第三款"平台类"App(前两款是 App 23 情绪漂流瓶和 App 29 反向导师平台)。三款平台类 App 的核心机制都是"连接两端用户":
| App | 连接双方 | 连接方式 | 核心状态 |
|---|---|---|---|
| 23 情绪漂流瓶 | 倾诉者 ↔ 回信者 | 随机匹配 | isReplied |
| 29 反向导师 | 学员 ↔ 导师 | 申请指导 | requests/connections |
| 35 二手流转 | 卖家 ↔ 买家 | 联系卖家 | interested |
三款 App 的连接方式从"随机"到"申请"到"联系",一步步从异步社交走向即时交易。
1.3 三十五款 App 全景
App 数量: 35 代码总行数: ~19,100 行 编译错误数: ~307 个 博客总字数: ~350,000 字 技术博客数: 35 篇2. 产品概念与物品模型
2.1 功能需求
用户故事 1:我想看看附近有没有二手母婴用品 用户故事 2:我想按品类筛选物品 用户故事 3:我想了解物品的详细信息 用户故事 4:我想联系卖家表达购买意向 功能清单: ├── F1: 好物列表(12 件物品) ├── F2: 分类标签筛选(5 个标签) ├── F3: 物品卡片(名称/价格/成色/距离) ├── F4: 原价删除线展示 ├── F5: 详情弹窗 ├── F6: 联系卖家 ├── F7: 已联系列表 └── F8: 联系状态管理2.2 物品数据模型
interfaceItem{id:number;name:string;// 物品名称emoji:string;// 图标price:number;// 二手价格origPrice:number;// 原价cond:string;// 成色desc:string;// 描述tag:string;// 分类dist:string;// 距离}8 个字段覆盖了二手物品展示所需的全部信息。价格相关字段有两个(price和origPrice),用于展示折扣力度。
2.3 物品定价策略
12 件物品的价格覆盖从 ¥25(儿童绘本)到 ¥2500(微单相机)的广泛区间。折扣率在 60%-80% 之间:
| 品类 | 平均原价 | 平均售价 | 平均折扣率 |
|---|---|---|---|
| 母婴 | ¥700 | ¥168 | 76% |
| 数码 | ¥4233 | ¥1733 | 59% |
| 家电 | ¥383 | ¥87 | 77% |
| 乐器 | ¥1160 | ¥433 | 63% |
整体平均折扣率约 68%,与线下二手交易的实际折扣率一致。
3. 两 Tab 架构设计
3.1 两 Tab 配置
build(){Stack(){Column().backgroundColor(C.bg)Column(){this.buildHeader()if(this.activeTab===0)this.buildBrowseTab()elsethis.buildMyTab()this.buildTabBar()}if(this.showDetail)this.buildDetailOverlay()}}本 App 只使用两个 Tab(而不是系列标准的三 Tab)。这是基于使用场景的设计决策:
| Tab | 图标 | 功能 | 使用场景 |
|---|---|---|---|
| 0 | 🏠 | 好物 — 浏览 + 筛选 + 联系 | 主要操作页 |
| 1 | 📋 | 我的 — 已联系列表 | 查看已联系物品 |
为什么不是三 Tab:二手流转 App 的核心操作是"浏览→联系",不需要第三个 Tab 来展示"发布"或"收藏"。发布功能对二手平台来说是核心功能,但对概念验证型 App 来说不是必需的。
3.2 两 Tab 数据流
浏览 Tab → 分类筛选 → 物品列表 → 点击查看详情 ↓ ↓ 详情弹窗 ←──────────── 点击"联系卖家" ↓ interested 数组更新 → 我的 Tab 同步数据流比三 Tab App 更简短——从浏览到联系只需要 2 次点击。
3.3 两 Tab 适配的 UI 调整
Tab Bar 从三个按钮改为两个按钮后,居中对齐的视觉权重需要调整:
.padding({left:48,right:48})左右 padding 增大,让两个按钮在视觉上处于"居中位置",避免按钮偏向一侧的不平衡感。
4. 分类标签筛选系统
4.1 标签配置
constTAGS:string[]=['全部','母婴','数码','家电','乐器'];5 个标签,每个对应一个垂直品类 + 一个"全部"。标签数量 5 个,在手机屏幕上刚好铺满一行。
4.2 标签渲染
Row(){ForEach(TAGS,(tag:string,idx:number)=>{Text(tag).fontSize(14).fontColor(this.selectedTag===idx?Color.White:C.text).padding({left:14,right:14,top:5,bottom:5}).backgroundColor(this.selectedTag===idx?C.primary:C.bgLight).borderRadius(12).margin({right:6}).onClick(()=>{this.selectedTag=idx;})},(tag:string)=>tag)}选中态为白字 + 主色背景,未选中为深色字 + 浅色背景。borderRadius(12)形成药丸形状。
4.3 筛选逻辑
getFiltered():Item[]{if(this.selectedTag===0)returnITEMS;consttag=TAGS[this.selectedTag];constresult:Item[]=[];for(constitemofITEMS){if(item.tag===tag)result.push(item);}returnresult;}筛选方法被两个地方调用:buildBrowseTab(ForEach 渲染列表)和buildDetailOverlay(详情弹窗获取当前物品)。如果切换分类标签时当前选中的物品不在新分类中,详情弹窗不会打开。
5. 物品卡片组件设计
5.1 卡片布局
┌──────────────────────────────────────┐ │ 🚼 ¥299 │ │ 宝宝婴儿车 ¥1200 │ │ 9成新 · 1.2km │ │ │ │ 用了不到5次,孩子长大了用不上... │ │ │ │ 母婴 💬 联系 │ └──────────────────────────────────────┘卡片分为三个区域:
- 顶部信息行:左侧 emoji + 名称 + 成色/距离;右侧价格(折扣价大字 + 原价小字删除线)
- 描述行:2 行以内的描述文字
- 底部操作行:左侧分类标签;右侧联系按钮
5.2 价格展示
Text('¥'+item.price).fontSize(20).fontColor(C.warm).fontWeight(FontWeight.Bold)Text('¥'+item.origPrice).fontSize(11).fontColor(C.textMuted).decoration({type:TextDecorationType.LineThrough})折扣价 20sp 暖橙色,原价 11sp 灰色删除线。两个价格的视觉权重差异明显——用户第一眼看到的是"多少钱",第二眼看到的是"原价多少"。
5.3 已联系状态
.backgroundColor(this.isInterested(item.id)?C.bgLight:C.bgCard)已联系物品的卡片背景变为浅绿色(C.bgLight),按钮变为灰色"已联系 ✓"。与"临期食品救援"(App 32)的设计一致。
6. 详情弹窗与联系流程
6.1 弹窗布局
┌──────────────────────────────┐ │ 🚼 │ │ 宝宝婴儿车 │ │ ───────────────────────── │ │ ¥299 原价 ¥1200 9成新 │ │ 📍 1.2km · 母婴 │ │ │ │ 📝 描述 │ │ 用了不到5次,孩子长大了… │ │ │ │ 💬 联系卖家 │ └──────────────────────────────┘弹窗高度 72%,从上到下:大 emoji(64sp)→ 名称 → 分割线 → 价格/成色 → 距离/分类 → 描述 → 联系按钮。
6.2 内联访问模式
本 App 的详情弹窗没有使用const item = this.getFiltered()[this.selectedItem],而是所有字段通过this.getFiltered()[this.selectedItem].xxx内联访问。
这是系列中代码最"冗余"的弹窗——this.getFiltered()[this.selectedItem]重复了 8 次。但这是为了遵守 @Builder 中不能使用 const 的约束。
如果 ArkTS 允许在 @Builder 中使用 const,弹窗代码可以简化为:
// ❌ 不允许:@Builder 中使用 constconstitem=this.getFiltered()[this.selectedItem];Text(item.emoji)Text(item.name)// ✅ 允许:内联访问Text(this.getFiltered()[this.selectedItem].emoji)Text(this.getFiltered()[this.selectedItem].name)6.3 联系流程
expressInterest(id:number):void{this.interested=[id,...this.interested];promptAction.showToast({message:'📩 已联系卖家!等待回复'});}3 行代码完成联系操作:ID 头部插入 + Toast 提示。从 App 29(反向导师平台)开始使用的"数组头部插入 + Toast"模式,到本 App 已经是第三次复用。
7. 联系系统与状态管理
7.1 状态设计
@Stateinterested:number[]=[];一维数组存储已联系物品的 ID。复杂度 O(1) 查询(indexOf(id))。
7.2 状态查询
isInterested(id:number):boolean{returnthis.interested.indexOf(id)>=0;}这个方法在 ForEach 的每次渲染中被调用(每张卡片都调一次)。对于最多 12 件物品来说,12 次 indexOf 遍历的性能损耗可以忽略。
7.3 我的 Tab
Text('已联系 '+this.interested.length+' 件')ForEach(this.interested,(id:number)=>{this.buildMyCard(id)},(id:number)=>'m'+id.toString())我的 Tab 读取interested数组中的 ID,调用buildMyCardBuilder 方法渲染每张卡片。使用 Helper 方法模式(getMyEmoji、getMyName等)在 @Builder 中安全获取数据。
8. 筛选状态联动机制
8.1 筛选与详情的联动
当用户在详情弹窗中查看一个物品时,如果切换分类标签,会发生什么?
// 详情弹窗中根据 getFiltered() 获取当前物品this.getFiltered()[this.selectedItem]// 如果分类从"全部"切换到"母婴"// getFiltered() 的返回值从 12 件变为 4 件// selectedItem 可能超出新数组的范围解决方案:详情弹窗打开时固定selectedItem的值,切换标签不会重置selectedItem。如果用户在分类筛选状态下打开详情弹窗后切换标签,selectedItem可能会指向新分类中的不同物品。
这个问题在当前版本中没有专门处理(因为弹窗打开时用户不太可能切标签),但后续版本可以通过在切换标签时关闭弹窗来解决:
.onClick(()=>{this.selectedTag=idx;this.showDetail=false;// 切换标签时关闭弹窗})8.2 筛选与联系的联动
筛选不会影响interested数组——无论切换到哪个分类,已联系物品始终显示为"已联系"状态。因为isInterested()查询的是全局interested数组,不受selectedTag影响。
9. 编译错误全记录
9.1 错误概览
本 App 共出现3 个编译错误。
| # | 错误代码 | 位置 | 原因 | 修复 |
|---|---|---|---|---|
| 1 | 10905209 | ForEach 回调 | const item = this.findById(id) | 提取 Builder 方法 |
| 2 | 10905209 | buildDetailOverlay | const item = this.getFiltered()[this.selectedItem] | 全部内联 |
| 3 | 10905209 | buildDetailOverlay 内 | 同上 | 同上 |
9.2 两次 Builder 提取
第一次提取:我的 Tab 中的 ForEach 使用了const item = this.findById(id)。提取为buildMyCard(id)Builder 方法。
第二次提取(实际是内联化):详情弹窗中的const item没有提取为 Builder 方法(因为 Builder 方法不能返回变量供后续使用),而是将所有item.xxx替换为this.getFiltered()[this.selectedItem].xxx。
// ✏️ 修改前:8 行代码声明了 const 变量constitem=this.getFiltered()[this.selectedItem];Text(item.emoji)Text(item.name)Text(item.price+'')Text(item.desc)// ✏️ 修改后:每行独立访问Text(this.getFiltered()[this.selectedItem].emoji)Text(this.getFiltered()[this.selectedItem].name)Text(this.getFiltered()[this.selectedItem].price+'')Text(this.getFiltered()[this.selectedItem].desc)9.3 三十五款 App 的错误数趋势
App 1: 16 ─── App 8: 4 │ App 16: 4 │ 稳定期 App 24: 48 │ AI 探索 App 28: 8 │ 预览器 App 31: 0 │ 零错误 🏆 App 32: 6 │ 引号问题 App 33: 0 │ 零错误 🏆 App 34: 1 │ Text 类型 App 35: 3 ─── Builder constApp 35 的 3 个错误全部是 10905209(Builder const 约束),没有新技术错误。从 App 24 到 App 35,新技术错误(非 10905209)的出现频率越来越低——不是因为不再写新代码,而是因为所有新技术错误都已经被发现并规避了。
10. 第三十五款 App 全景回顾
10.1 数据总览
| 指标 | 数值 |
|---|---|
| 代码行数 | 266 行 |
| 编译错误数 | 3 个 |
| @State 变量 | 4 个 |
| @Builder 方法 | 6 个 |
| 物品数量 | 12 件 |
| 品类数量 | 4 个 |
| Tab 数量 | 2 个 |
| 弹窗数 | 1 个 |
| 外部依赖 | 0 个 |
10.2 三款平台类 App 对比
| 23 情绪漂流瓶 | 29 反向导师 | 35 二手流转 | |
|---|---|---|---|
| 代码行数 | 447 | 373 | 266 |
| 错误数 | 1 | 6 | 3 |
| 连接方式 | 随机匹配 | 申请指导 | 联系卖家 |
| 状态数组 | isReplied | requests + connections | interested |
| Tab 数 | 3 | 3 | 2 |
| 弹窗数 | 3 | 1 | 1 |
二手流转(App 35)是系列中最精简的平台类 App——最少的行数、最少的 Tab、最少的弹窗。
10.3 平台类 App 的简化趋势
从 App 23 到 App 35,平台类 App 的复杂度在持续下降:
App 23:3 Tab + 3 弹窗 + 随机匹配 = 复杂平台 App 29:3 Tab + 1 弹窗 + 申请机制 = 标准平台 App 35:2 Tab + 1 弹窗 + 直接联系 = 极简平台这个简化不是功能减少,而是平台逻辑从"异步社交"简化为"直接联系"。二手交易不需要匹配、不需要申请、不需要等待确认——只需要告诉卖家"我想要"就够了。
10.4 三十五款 App 的平均代码量
总代码行数:约 19,100 行 App 数量:35 平均每款 App:约 546 行 中位数:约 480 行 最少的 5 款:188(28), 260(35), 268(31), 280(34), 298(33) 最多的 5 款:1320(3), 1038(5), 955(2), 953(4), 907(24)从第 28 款之后,代码量稳定在 200-300 行的区间。这个区间的 App 功能完整但没有冗余。200-300 行可能是 ArkTS 单文件 App 的"最佳代码量"——足够实现一个完整的应用,又不会复杂到难以维护。
11. 结语
11.1 二手流转的社会意义
每一件被丢弃的二手物品,背后都有一段故事:孩子长大了用不上的婴儿车、学了一学期闲置的尤克里里、搬家带不走的电饭煲。
二手流转 App 做的事情很简单:让这些物品找到新的主人。不是慈善,不是环保主义,就是最朴素的"我用不上了,但你可能需要"。
本 App 12 件物品的平均折扣率 68%。买家省钱、卖家变现、地球减少浪费——三赢。
11.2 从第 1 款到第 35 款的观察
写了 35 款 App 之后,最清楚的一个观察是:好的 App 不需要很多功能,只需要把核心功能做好。
二手流转 App 只有两个 Tab——好物列表和已联系列表。没有用户注册、没有商品发布、没有聊天系统、没有评价系统。但如果用这 266 行代码做成的 App 真的帮一个人卖掉了闲置的婴儿车,或者帮一个人买到了便宜的二手相机——那它的价值就超过了那些功能完整但无人使用的超级 App。
11.3 给开发者的建议
- 平台类 App 从直接联系开始——不要一开始就做匹配系统、申请系统、等待系统。让用户直接联系,验证需求后加功能
- @Builder 中的 const 是系列最高频错误——第 35 款也没有完全避免。但修复速度从第 1 款的 10 分钟降到了第 35 款的 <1 分钟
- 35 款 App 的平均代码量约 546 行——你的 App 不需要很多代码,266 行够做一款二手交易 App
- 详情弹窗的内联访问虽然冗余但可行——重复 8 次
this.getFiltered()[this.selectedItem]在 266 行的总代码量中不算什么
11.4 致谢
35 款 App、35 篇博客、约 350,000 字。
从第 1 款到第 35 款,这个系列的终点不是 35,而是每个读到这里的你的第 1 款 App。
现在,打开 DevEco Studio,去创造属于你自己的 App 吧——不管它有多少行代码。
附录 A:核心代码速查
分类筛选
getFiltered():Item[]{if(this.selectedTag===0)returnITEMS;constresult:Item[]=[];for(constitemofITEMS){if(item.tag===TAGS[this.selectedTag])result.push(item);}returnresult;}联系卖家
expressInterest(id:number):void{this.interested=[id,...this.interested];promptAction.showToast({message:'📩 已联系卖家!等待回复'});}Helper 方法示例
getMyName(id:number):string{consti=this.findById(id);returni!==undefined?i.name:'';}getMyPrice(id:number):number{consti=this.findById(id);returni!==undefined?i.price:0;}附录 B:色板
| 变量 | 值 | 用途 |
|---|---|---|
C.bg | #F0F6F8 | 主背景 |
C.primary | #4A9B9B | 主色(青绿) |
C.warm | #E8927C | 价格 |
C.accent | #3D8B8B | 标签/已联系 |