首页的四个旅行工具入口中行程规划和美食指南代表了两种不同的数据处理方式——行程规划是用户可操作的动态列表美食指南是从数据源聚合的只读列表。从截图和代码来看两个页面在视觉上都用了白色卡片 纵向滚动的结构但背后的状态管理和数据流完全不同。完整效果TripPlanner行程规划的待办交互截图分析从截图看行程规划页面的结构很清晰顶部导航栏返回 标题 保存一条进度条显示已完成 0/5 天一个输入框带添加按钮下方是五张行程卡片Day 1 到 Day 5每张卡片右侧有一个圆形勾选框。整体风格是白色卡片 浅灰背景和 App 的其他页面保持一致。数据结构interfacePlanItem{day:number;title:string;desc:string;done:boolean}四个字段定义了每个行程项的信息天数序号、标题、描述、完成状态。done是唯一会动态变化的字段——用户勾选时它在true和false之间切换。默认数据是五天的行程Statedays:PlanItem[][{day:1,title:抵达 市区探索,desc:入住酒店适应时差傍晚城市漫步,done:false},{day:2,title:文化之旅,desc:参观博物馆和历史遗迹,done:false},{day:3,title:自然风光,desc:郊外一日游亲近大自然,done:false},{day:4,title:美食巡礼,desc:当地市场 特色餐厅,done:false},{day:5,title:自由活动 购物,desc:买纪念品享受悠闲时光,done:false},]勾选逻辑引用变更触发刷新privatetoggleDay(idx:number):void{this.days[idx].done!this.days[idx].doneconstcopy:PlanItem[][]for(leti:number0;ithis.days.length;i){copy.push(this.days[i])}this.dayscopy}这是整个页面最关键的技术点。ArkTS 的State追踪的是引用变更不是属性变更。如果只写this.days[idx].done !this.days[idx].done数组的引用没变State认为数据没有更新UI 不会重新渲染。解决办法是创建一个新数组把旧数据复制进去然后用this.days copy替换整个引用。这一步复制 替换是 ArkTS 状态管理里最常见的坑——新手很容易只改属性不改引用然后疑惑为什么 UI 不刷新。for循环复制而不是用slice()或展开运算符[...this.days]是因为 ArkTS 里数组方法的类型推断有时不够精确手动循环虽然啰嗦但类型安全。添加行程StatenewTitle:stringprivateaddDay():void{if(this.newTitle.trim().length0)returnthis.days.push({day:this.days.length1,title:this.newTitle,desc:自定义行程,done:false})this.newTitleconstcopy:PlanItem[][]for(leti:number0;ithis.days.length;i){copy.push(this.days[i])}this.dayscopy}添加流程三步走检查输入非空 → push 新对象到数组 → 复制数组触发刷新。新行程的day序号用this.days.length 1计算——原来有 5 天新添加的就是 Day 6。newTitle也是State所以this.newTitle 会清空输入框。两个State变量在同一方法里被修改ArkTS 会在方法结束后统一触发一次重新渲染不会出现两次刷新的问题。进度条Row(){Text(已完成 this.doneCount()/this.days.length 天).fontSize(13).fontColor(T2)Blank()Row(){}.width((this.doneCount()/this.days.length)*100).height(6).borderRadius(3).backgroundColor(A)}进度条的宽度是动态计算的(完成数 / 总数) × 100。5 天里完成了 3 天宽度就是3/5 × 100 60vp。Blank()把进度条推到右边和左侧文字形成左右分布。doneCount()方法和运动健康 App 里的计数逻辑一样——用for循环遍历数组统计done true的数量。每日卡片的勾选 UIStack({alignContent:Alignment.Center}){Row(){}.width(22).height(22).borderRadius(11).backgroundColor(item.done?A:transparent).border({width:1.5,color:item.done?A:#D0CED8})if(item.done){Text(✓).fontSize(12).fontWeight(FontWeight.Bold).fontColor(Color.White)}}.onClick((){this.toggleDay(idx)})勾选框用Stack实现——底层是一个 22×22 的圆形未选中时是透明背景 灰色边框选中时是橙色填充。上层用if (item.done)条件渲染一个白色勾号。Stack({ alignContent: Alignment.Center })让勾号自动居中。卡片左侧的竖线也根据完成状态改变颜色Column(){}.width(2).height(20).backgroundColor(item.done?A40:#F0EEF4)已完成的竖线用主色的 25% 透明度未完成的用浅灰色。这个细微的颜色变化配合勾选框和标题的删除线形成了三重视觉反馈——勾选框告诉你勾了删除线告诉你完成了竖线颜色告诉你这一项处理过了。CuisinePage数据聚合的只读列表美食指南页面是一张长列表每张卡片左侧是食物 emoji中间是名称带城市标签和描述右侧是价格。从截图看食物按城市分组排列——先是东京的寿司、章鱼烧、天妇罗然后是巴黎的可颂、法式蜗牛、马卡龙接着是巴厘岛的脏鸭餐。每个城市名称用橙色小标签标注在食物名称旁边。数据聚合逻辑StateallCuisines:FoodItem[][]aboutToAppear():void{constlist:FoodItem[][]for(leti:number0;iCITIES.length;i){for(letj:number0;jCITIES[i].cuisines.length;j){list.push({city:CITIES[i].name,emoji:CITIES[i].cuisines[j].emoji,name:CITIES[i].cuisines[j].name,desc:CITIES[i].cuisines[j].desc,price:CITIES[i].cuisines[j].price})}}this.allCuisineslist}双重for循环遍历所有城市的所有美食把嵌套结构拍平成一维数组。每个FoodItem额外加了一个city字段——原始Cuisine接口里没有这个字段但列表页面需要显示这道菜来自哪个城市所以必须在遍历时手动补上。这是数据聚合的一个典型模式原始数据的结构不一定满足展示需求需要在中间层做一次转换。aboutToAppear就是做这个转换的时机——在页面渲染之前把数据处理成 UI 需要的格式。为什么不直接在ForEach里遍历CITIES然后嵌套遍历cuisines因为那样每张卡片的城市信息需要通过闭包捕获外层循环变量在 ArkTS 里这种写法的类型推断容易出问题。先聚合再遍历数据流更清晰。FoodItem 接口interfaceFoodItem{city:string;emoji:string;name:string;desc:string;price:string}五个字段都是string类型包括价格。价格用字符串而不是数字是因为它包含了单位信息——“1000-5000日元”、“€1-3”、“$3-5/片”。如果用数字类型就只能存数值单位需要另外处理。城市标签的设计Row(){Text(f.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1)Text(f.city).fontSize(10).fontColor(A).padding({left:4,right:4,top:1,bottom:1}).backgroundColor(#FFF5F0).borderRadius(3).margin({left:6})}城市标签用极小的字号10px和浅橙色背景和食物名称形成明显的主次关系。#FFF5F0是主色#FF6B35的极浅版本大约 3% 不透明度视觉上是淡淡的橙色底色不会抢主信息的焦点。标签的内边距非常紧凑——左右 4px上下 1px。这种小标签设计在很多 App 里都能看到比如商品分类标签、状态标签它的核心原则是标签是辅助信息不应该比主信息更显眼。emoji 图标的底色容器Text(f.emoji).fontSize(32).width(56).height(56).borderRadius(14).backgroundColor(#F5F5F7).textAlign(TextAlign.Center)emoji 没有直接放在卡片上而是放在一个 56×56 的浅灰色圆角容器里。#F5F5F7是一个接近白色的浅灰给 emoji 提供了一个底座让它在白色卡片背景上不会显得太突兀。textAlign(TextAlign.Center)让 emoji 在容器内居中。emoji 本身的渲染在不同设备上可能有微小的位置偏差加了居中对齐后能保证视觉一致性。两个页面的状态管理对比维度TripPlannerCuisinePageState 数量2days, newTitle1allCuisines数据来源本地硬编码TravelData.ets数据变化时机用户操作时页面加载时一次性是否需要复制数组是每次操作后否只读用户交互勾选、添加无纯展示行程规划是读写型页面——用户可以改变数据每次改变都需要触发 UI 更新所以需要处理引用变更。美食指南是只读型页面——数据在aboutToAppear里加载一次之后不会再变所以不需要处理引用变更。这种区别决定了两个页面的代码复杂度。TripPlanner 的toggleDay和addDay都需要修改 复制 赋值三步而 CuisinePage 只需要一个aboutToAppear里的数据聚合。页面的复杂度不取决于 UI 有多花哨而取决于数据会怎么变化。页面导航的共性两个页面的导航栏结构完全一致——左边返回按钮中间标题右边操作区行程规划有保存按钮美食指南没有。返回按钮的实现也是一样的34×34 圆形rgba(0,0,0,0.03)背景系统左箭头图标点击调用router.back()。这种导航栏的统一性是整个旅行 App 的设计规范。无论页面内容多复杂导航栏始终保持相同的结构和交互用户不需要重新学习怎么返回。