鸿蒙 Next 二手流转 App 开发实战垂直品类 分类筛选 联系系统作者duluoSDK 版本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 情绪漂流瓶倾诉者 ↔ 回信者随机匹配isReplied29 反向导师学员 ↔ 导师申请指导requests/connections35 二手流转卖家 ↔ 买家联系卖家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¥16876%数码¥4233¥173359%家电¥383¥8777%乐器¥1160¥43363%整体平均折扣率约 68%与线下二手交易的实际折扣率一致。3. 两 Tab 架构设计3.1 两 Tab 配置build(){Stack(){Column().backgroundColor(C.bg)Column(){this.buildHeader()if(this.activeTab0)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.selectedTagidx?Color.White:C.text).padding({left:14,right:14,top:5,bottom:5}).backgroundColor(this.selectedTagidx?C.primary:C.bgLight).borderRadius(12).margin({right:6}).onClick((){this.selectedTagidx;})},(tag:string)tag)}选中态为白字 主色背景未选中为深色字 浅色背景。borderRadius(12)形成药丸形状。4.3 筛选逻辑getFiltered():Item[]{if(this.selectedTag0)returnITEMS;consttagTAGS[this.selectedTag];constresult:Item[][];for(constitemofITEMS){if(item.tagtag)result.push(item);}returnresult;}筛选方法被两个地方调用buildBrowseTabForEach 渲染列表和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%从上到下大 emoji64sp→ 名称 → 分割线 → 价格/成色 → 距离/分类 → 描述 → 联系按钮。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 中使用 constconstitemthis.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 我的 TabText(已联系 this.interested.length 件)ForEach(this.interested,(id:number){this.buildMyCard(id)},(id:number)mid.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.selectedTagidx;this.showDetailfalse;// 切换标签时关闭弹窗})8.2 筛选与联系的联动筛选不会影响interested数组——无论切换到哪个分类已联系物品始终显示为已联系状态。因为isInterested()查询的是全局interested数组不受selectedTag影响。9. 编译错误全记录9.1 错误概览本 App 共出现3 个编译错误。#错误代码位置原因修复110905209ForEach 回调const item this.findById(id)提取 Builder 方法210905209buildDetailOverlayconst item this.getFiltered()[this.selectedItem]全部内联310905209buildDetailOverlay 内同上同上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 变量constitemthis.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 个错误全部是 10905209Builder 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 二手流转代码行数447373266错误数163连接方式随机匹配申请指导联系卖家状态数组isRepliedrequests connectionsinterestedTab 数332弹窗数311二手流转App 35是系列中最精简的平台类 App——最少的行数、最少的 Tab、最少的弹窗。10.3 平台类 App 的简化趋势从 App 23 到 App 35平台类 App 的复杂度在持续下降App 233 Tab 3 弹窗 随机匹配 复杂平台 App 293 Tab 1 弹窗 申请机制 标准平台 App 352 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.selectedTag0)returnITEMS;constresult:Item[][];for(constitemofITEMS){if(item.tagTAGS[this.selectedTag])result.push(item);}returnresult;}联系卖家expressInterest(id:number):void{this.interested[id,...this.interested];promptAction.showToast({message: 已联系卖家等待回复});}Helper 方法示例getMyName(id:number):string{constithis.findById(id);returni!undefined?i.name:;}getMyPrice(id:number):number{constithis.findById(id);returni!undefined?i.price:0;}附录 B色板变量值用途C.bg#F0F6F8主背景C.primary#4A9B9B主色青绿C.warm#E8927C价格C.accent#3D8B8B标签/已联系