鸿蒙原生应用 ArkTS 表单工程:活动照片上传页九宫格增删与实时计数
鸿蒙原生应用 ArkTS 表单工程:活动照片上传页九宫格增删与实时计数
App 40「校园活动照片」上传页(Func2Tab),主题色
#6C5CE7紫色(violet),4 个 Tab 分别为首页(📸)、活动(🎉)、上传(📤)、我的(👤)。上传页采用"Header + 照片九宫格 + 表单卡 + 上传按钮"四区布局——白色单行 Header("上传照片"20 号加粗) + 3×3 照片九宫格(标题"选择照片 (3/9)"实时计数 + 已选照片 🖼️ 带右上角 ✕ 删除角标 + 虚线边框"+"添加格 + 空位占位,Grid 三列 1fr 1fr 1fr) + 表单卡(关联活动 4 胶囊单选 + 照片说明 TextArea) + 全宽紫色"上传照片"按钮(Toast"上传 N 张照片")。本篇基于40-event-photo/entry/src/main/ets/pages/Func2Tab.ets(共 99 行)逐段拆解,附 4 张实机截图。
一、整体结构:四区"Header + 九宫格 + 表单 + 按钮"布局
上传页是"品牌头部 + 照片选择 + 信息填写 + 提交"的四区布局:
build() {
Column() {
this.Header()
Scroll() {
Column({ space: 16 }) {
this.PhotoGrid()
this.FormCard()
Button('上传照片').fontSize(15).fontColor('#FFFFFF').backgroundColor(C.primary)
.width('100%').height(48).borderRadius(D.rMd)
.onClick(() => { promptAction.showToast({ message: '上传 ' + this.photoCount + ' 张照片' }); })
}
.width('100%')
.padding({ left: D.pad, right: D.pad, top: 16, bottom: D.pad + this.safeBottom + 20 })
}
.layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
}
.width('100%').height('100%').backgroundColor(C.bg)
}
4 块结构:
- Header — 单行品牌头("上传照片")
- PhotoGrid — 3×3 九宫格,标题实时显示
(3/9),是整页最核心的交互区 - FormCard — "关联活动"单选胶囊 + "照片说明"多行输入
- 上传按钮 — 全宽 48 高紫色主按钮,Toast 汇报上传张数
三个 @State 贯穿全页:photoCount(已选照片数)、activeEvent(关联活动索引)、caption(照片说明文本)——状态清晰、职责单一。
二、Header:单行标题 + 安全区避让
Header() {
Row() { Text('上传照片').fontSize(20).fontWeight(FontWeight.Bold).fontColor(C.text) }
.width('100%').height(this.safeTop + 56).padding({ top: this.safeTop, left: D.pad, right: D.pad })
.backgroundColor(C.card).alignItems(VerticalAlign.Bottom)
}
单行 20 号加粗、safeTop + 56 高度、白底、文字底部对齐——与活动页标题行同款,系列二级页统一范式。

三、PhotoGrid:3×3 九宫格的三态渲染
这是整页技术含量最高的部分:
PhotoGrid() {
Column({ space: 10 }) {
Text('选择照片 (' + this.photoCount + '/9)').fontSize(14).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%')
Grid() {
ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
GridItem() {
if (i < this.photoCount) {
Stack({ alignContent: Alignment.TopEnd }) {
Row() { Text('🖼️').fontSize(30) }
.width('100%').aspectRatio(1).backgroundColor(C.primarySoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center)
Text('✕').fontSize(12).fontColor('#FFFFFF')
.width(20).height(20).textAlign(TextAlign.Center).backgroundColor('#88000000').borderRadius(10)
.margin({ top: 4, right: 4 })
.onClick(() => { if (this.photoCount > 0) { this.photoCount--; } })
}
} else if (i === this.photoCount) {
Column() { Text('+').fontSize(32).fontColor(C.textDim) }
.width('100%').aspectRatio(1).backgroundColor(C.cardSoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center)
.border({ width: 1, color: C.stroke, style: BorderStyle.Dashed })
.onClick(() => { this.photoCount++; })
} else {
Column().width('100%').aspectRatio(1)
}
}
}, (i: number) => i.toString())
}.columnsTemplate('1fr 1fr 1fr').columnsGap(10).rowsGap(10).width('100%')
}
}
技术拆解:
- Grid 三列布局:
Grid()容器 +columnsTemplate('1fr 1fr 1fr')声明三列等宽,columnsGap(10)/rowsGap(10)控制行列间距;ForEach生成 6 个GridItem(0~5),配合aspectRatio(1)让每个格子在宽度已知时自动等比例变为正方形——三行两列的 2/3 屏宽九宫格就这样成型。 - 三态条件渲染(
i与photoCount的关系决定每个格子渲染什么):- 已选照片(
i < photoCount):Stack({ alignContent: Alignment.TopEnd })内放 🖼️ 占位图(30 号、浅紫底、圆角 10)与右上角 ✕ 删除角标——20×20 圆形、#88000000半透明黑底白字,margin({ top: 4, right: 4 })贴角内缩,点击photoCount--。 - 加号格(
i === photoCount):浅底 + 虚线边框(border({ style: BorderStyle.Dashed }))+ 32 号灰"+",点击photoCount++——它是"下一个空位"的添加入口,永远紧跟已选照片之后。 - 空位占位(
i > photoCount):空白Column,纯占位保持九宫格形状。
- 已选照片(
- 计数闭环:标题"选择照片 (3/9)"与加号格位置、已选格数量都由
photoCount单一状态驱动——加一张、标题变 4/9、加号格后移一格;删一张则反向,状态永远自洽。
为什么是 6 个 GridItem 而非 9 个:Demo 简化了"最多选 9 张"的上限(标题 3/9 暗示 9),但网格只渲染 6 格(两行)——因为 6 格对演示"3 张 + 加号 + 空位"三态已足够,9 格会多占一屏空间。真实产品把 ForEach 数组扩到 0~8 即可。
四、FormCard:关联活动 + 照片说明
FormCard() {
Column({ space: 14 }) {
Column({ space: 6 }) {
Text('关联活动').fontSize(13).fontColor(C.textSub).width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.events, (e: string, idx: number) => {
Text(e).fontSize(12)
.fontColor(this.activeEvent === idx ? '#FFFFFF' : C.textSub)
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor(this.activeEvent === idx ? C.primary : C.cardSoft).borderRadius(14)
.margin({ right: 10, bottom: 10 })
.onClick(() => { this.activeEvent = idx; })
}, (e: string) => e)
}.width('100%')
}
Column({ space: 6 }) {
Text('照片说明').fontSize(13).fontColor(C.textSub).width('100%')
TextArea({ placeholder: '添加照片描述...' })
.backgroundColor(C.cardSoft).borderRadius(D.rSm).height(80)
.onChange((v: string) => { this.caption = v; })
}
}
.width('100%').padding(16).backgroundColor(C.card).borderRadius(D.rLg).border({ width: 1, color: C.stroke })
}
表单细节:
- 关联活动单选:
Flex换行排布 4 个活动胶囊(迎新晚会/秋季运动会/校园音乐节/话剧公演),@State activeEvent记录选中索引,选中紫底白字、未选浅底灰字——单选胶囊与 App 39 兴趣页的多选胶囊同构,但语义是"单选"(索引而非数组),更轻量。 - 关联逻辑:选中的活动即照片归属相册——上传后照片进入该活动的照片集,与首页相册/活动页的照片数直接挂钩。
- 照片说明:
TextArea80 高多行输入、浅底圆角 10、placeholder提示"添加照片描述...",onChange把输入写入@State caption。 - 数据绑定:
events数组(15 行)与活动页 4 个活动完全同名——上传页关联的活动即活动页展示的活动,全 App 数据源统一。
五、上传按钮与提交链路
Button('上传照片').fontSize(15).fontColor('#FFFFFF').backgroundColor(C.primary)
.width('100%').height(48).borderRadius(D.rMd)
.onClick(() => { promptAction.showToast({ message: '上传 ' + this.photoCount + ' 张照片' }); })
全宽 48 高紫色主按钮,点击 Toast"上传 N 张照片"——N 与九宫格计数一致,用户从选照片到提交的每一步反馈都有明确的数据支撑。真实实现中,点击上传应执行"读取相册图片 → 压缩 → 携带 activeEvent 与 caption 提交服务端 → 成功回调更新相册 count 并跳转回首页"的完整链路,Demo 的 Toast 是这条链路的占位。
六、跨页数据自洽:上传页是数据的"生产端"
上传页是全 App 唯一的数据生产入口,与消费端严格自洽:
- 关联活动 4 个胶囊 ↔ 活动页 4 个活动(迎新晚会/秋季运动会/校园音乐节/话剧公演)同名同序——上传即写入对应活动的照片集。
- 照片归属:上传成功后,对应活动的"📷 N 张照片"、首页相册的
count、我的页"我的照片"统计都应 +N——Demo 静态展示,产品化后上传动作将驱动全 App 数据刷新。 - 我的页"96 我的照片":96 是历史累计上传量,本次会话上传 3 张后应变为 99——统计的自洽演进是"上传 → 我的"闭环的验证。

七、实机截图与交互演示
本节结合 4 张实机截图,逐张还原上传页的交互链路。
1. 上传页首屏:3/9 九宫格 + 表单卡
第一张截图是上传页默认首屏:顶部"上传照片"标题,下方"选择照片 (3/9)"与 3×3 九宫格——前 3 格为 🖼️ 已选照片(各带右上角 ✕ 角标)、第 4 格为虚线边框"+"添加格、第 5/6 格为空位占位;往下是"关联活动"4 个胶囊(第一个"迎新晚会"紫色选中态)与"照片说明"TextArea,底部可见紫色"上传照片"按钮。
2. 点击"+":添加第 4 张照片
第二张截图是点击虚线"+"格后的状态:标题变为"选择照片 (4/9)",第 4 格从"+"变为 🖼️ 已选照片(带 ✕ 角标),"+"格自动后移到第 5 格——photoCount 从 3 变为 4,三态条件渲染即时重绘,计数与格子位置完全联动。
3. 点击"✕":删除第一张照片
第三张截图是点击第一张照片右上角"✕"后的状态:标题回到"选择照片 (3/9)",第一格恢复为"+"添加格,后续照片前移——photoCount-- 触发同一套三态逻辑反向流转。删除角标的 #88000000 半透明黑底在浅紫图上清晰可见,是"图片右上角操作"的标准交互。
4. 点击"上传照片":提交 Toast
第四张截图是点击全宽"上传照片"按钮后的反馈:弹出"上传 3 张照片"Toast——数字与九宫格当前计数(3/9)完全一致,验证了提交逻辑读取的是同一个 photoCount 状态源,选择与提交的数据流闭环成立。
八、扩展思考:照片选择的真实产品实现
Demo 用 🖼️ emoji 占位模拟照片,真实产品的照片选择链路复杂得多:
- 相册访问:需通过
@ohos.file.photoAccessHelper申请相册权限并读取本地照片,用PhotoViewPicker(ArkTS 原生照片选择器)让用户从系统相册多选图片,选中的PhotoViewPicker返回的 uri 数组即photoCount的数据源。 - 压缩与上传:原图可能数 MB,需先用
@ohos.multimedia.image做采样压缩(createImagePacker或ImagePacker),再走@ohos.net.http或@kit.NetworkKit分片上传到对象存储。 - 九宫格升级:真实照片网格应展示缩略图(
Image+objectFit(ImageFit.Cover)),配LazyForEach避免大图集卡顿;✕ 角标保留,+格调用系统选择器。 - 进度反馈:上传中显示进度环/百分比,逐张完成后对应格子角标从"✕"变为"✓"或"上传成功"标记——Demo 的即时计数是这一链路的最小演示。
九、扩展思考:Grid 布局 vs 其他照片排布方案
- Grid 网格:适合"行列固定、格子等大"的场景(九宫格/照片墙),
columnsTemplate+rowsTemplate精确控制,本页是标准用法。 - Flex 换行:适合"数量不定、宽度自适应"的标签流(如关联活动胶囊)——本页 FormCard 正是 Flex 方案,两种布局在同页互补。
- WaterFlow 瀑布流:适合"高度不等"的相册展示(如首页),Grid 的等高格子反而破坏瀑布错落感——同一 App 内按场景混用三种布局,是 ArkUI 布局能力的完整展示。
- 可拖拽排序:若支持用户调整照片顺序,可在 GridItem 上加
onDragStart+ 拖拽事件,属于进阶交互,Demo 未涉及。
十、常见问题与开发避坑
- photoCount 越界:
+格点击photoCount++无上限校验——若 ForEach 扩到 9 格,用户最多加到 9 后"+"格消失,点击其他位置无碍;但若photoCount超过格子数会出现全空(无"+"格)的尴尬态,应加if (this.photoCount < 9)上限。 - ✕ 角标的点击穿透:
Stack中 ✕ 与底层 🖼️ 是叠放关系,✕ 的onClick会拦截点击,不会触发下层事件——当前设计正确;若想点击图片本体打开预览,需给图片单独挂事件。 - Dashed 虚线边框的圆角:加号格同时有
borderRadius(D.rSm)与虚线边框,虚线沿圆角裁剪正常;但若圆角过大(>10),虚线与圆角的衔接可能出现锯齿,注意控制圆角值。 - TextArea 高度固定:
height(80)固定高度,输入多行文字会自动滚动而非增高——若希望自适应增高,需监听内容变化动态调整高度。 - 表单校验缺失:当前无照片时也能点"上传 0 张照片"(Toast"上传 0 张照片")——工程化应在
onClick校验photoCount > 0与activeEvent已选,未满足则提示"请先选择照片"。
十一、扩展思考:照片元数据与 EXIF 的工程价值
上传页的"照片说明"(caption)是用户主动补充的元数据,而真实照片还自带大量被动元数据,值得展开:
- EXIF 信息:照片文件头自带拍摄时间、设备型号、GPS 定位、曝光参数等 EXIF 数据——用
@ohos.file的图片接口可读取。上传时自动提取拍摄时间,可替代用户手动填日期。 - GPS 隐私:EXIF 中的定位信息是敏感数据(可暴露宿舍/教室位置),合规的上传流程应在客户端剥离 GPS 字段再上传——隐私保护是照片类产品的红线。
- 自动分类:按拍摄时间(活动当天)、按地点(体育馆/礼堂)自动归入对应活动相册,减少用户手动关联成本——上传页"关联活动"胶囊其实是在做"人肉分类",产品化后可由服务端图像识别/时间聚类自动推荐。
- 图片压缩策略:原图 3-5MB 上传耗流量,客户端应按用途分层压缩——缩略图(列表用,~100KB)+ 原图(预览用,~2MB),对应首页瀑布流与详情页全屏两种消费场景。
- 水印与版权:校园照片社区常给照片加"校园摄影师 @昵称"水印,既防搬运又传播创作者品牌——与我的页"校园摄影师"昵称联动,上传即打标。
十二、扩展思考:上传队列与断点续传设计
Demo 的"上传 3 张照片"Toast 背后,真实产品需要一套健壮的上传引擎:
- 上传队列:多张照片应串行/并发受限地上传(如同时 2 张),每张维护独立状态(等待中/上传中/成功/失败),九宫格角标从"✕"扩展为"进度环/✓/重试"——
photoCount状态机升级为数组状态对象。 - 失败重试:网络抖动导致某张失败时,自动重试 3 次(指数退避),仍失败则标记"重试"按钮——不因单张失败阻塞整批。
- 断点续传:大图分片上传,记录已传分片,App 被杀后恢复续传——
@ohos.data.preferences持久化队列状态,下次打开自动续传未完成任务。 - 弱网策略:监听
@ohos.net.connection网络类型,Wi-Fi 下才允许原图上传,移动网络仅传缩略图,兼顾体验与流量成本。 - 后端接口设计:
POST /api/photos(multipart)+ 携带eventId(关联活动)、caption(描述)、exif(元数据)——返回照片 id,App 据其更新首页相册 count 与我的页统计,完成"上传 → 展示"闭环。

十三、系列横向对比:上传/发布类页面的范式演进
上传页属于"内容生产"类页面,本系列多个 App 提供了可对照的发布范式:
| 范式 | 代表 App | 内容输入 | 附加表单 | 提交反馈 |
|---|---|---|---|---|
| 照片九宫格 | App 40 上传页 | 3×3 网格选图 | 关联活动 + 说明 | Toast 张数 |
| 表单校验 | App 38 发布页 | 技能名称输入 | 分类 + 熟练度 + 想学 | 校验 Toast |
| 资料表单 | App 34 上传页 | 文件选择 | 分类 + 描述 | Toast |
| 文本发布 | App 35 发布页 | 笔记标题+正文 | 分类胶囊 | Toast |
App 40 的九宫格选图是系列中唯一的多图选择器——它把"选择照片"这一核心动作放大成页面主体(3×3 网格 + ✕ 删除 + 虚线添加),而关联活动/照片说明退居为辅助表单,主次分明。相比之下 App 38 发布页是"文字输入为主、胶囊为辅",因为技能发布的核心信息是文字描述。发布页的布局重心永远放在"核心内容输入"上——图片内容用网格、文字内容用输入框,其余字段一律折叠为辅助区。
三态条件渲染(已选/加号/空位)是 App 40 上传页区别于其他发布页的技术亮点——它用 photoCount 单一状态驱动三种格子形态,比 App 38 的固定字段表单更"状态驱动"。若把九宫格方案与 App 38 的校验逻辑结合(选图必选 + 关联活动必选 + 说明可选),就是一个接近完整的产品级发布器。

十四、开发者视角:九宫格状态机的调试技巧
- photoCount 边界测试:重点测试 0、1、9 三个边界值——
photoCount = 0时首格应为"+"、photoCount = 9(若网格扩到 9 格)时应全为已选且无"+"格、点 ✕ 到 0 后不应出现负数——边界不清会导致i === this.photoCount分支永远无法命中。 - 三态渲染的单元验证:三态条件
if (i < photoCount) / else if (i === photoCount) / else可用不同 photoCount 值(0/1/3/5)配合 dumpLayout 逐格验证格子形态,快速定位条件写错的分支。 - 点击事件的冒泡:✕ 角标在 Stack 上层、🖼️ 在下层,点击 ✕ 时确认不触发下层事件(当前正确)。若将来给 🖼️ 加"预览大图"事件,注意 ✕ 的
stopPropagation处理,否则点 ✕ 会同时打开预览。 - aspectRatio 与 Grid 的兼容:
aspectRatio(1)在 Grid 单元格内依赖列宽推导高度——若columnsTemplate写错(如 '1fr 1fr'),格子会变宽高比失衡,先检查模板字符串语法。 - TextArea 与键盘:点击 TextArea 弹出软键盘会压缩 Scroll 可视区,上传按钮可能被键盘遮挡——用
expandSafeArea([SafeAreaType.KEYBOARD])或滚动到按钮位置,保证键盘弹出时仍可操作。
十五、FAQ 与一句话总结
Q1:为什么选择照片用 emoji 占位? 原型阶段模拟"已选照片"的状态流(3/9 → 加号位置 → ✕ 删除)才是重点,emoji 占位让这套状态机可完整演示。真实产品替换为 Image 缩略图,状态机逻辑零改动。
Q2:最多能选几张? 标题"选择照片 (3/9)"暗示上限 9 张,但网格只渲染 6 格。产品化时把 ForEach 数组扩到 0~8、+ 格加 9 上限校验即可,UI 会自动补全 3 行九宫格。
Q3:关联活动为什么是单选? 一张照片属于一个活动(一个相册),单选符合数据模型;若产品支持"同一照片多活动"(如跨活动合影),改为多选数组即可。
Q4:TextArea 的输入内容去哪里了? onChange 写入 @State caption,Demo 未提交服务端。真实实现随上传请求体一并提交,作为照片描述展示在相册详情页。
Q5:上传成功后页面会怎样? Demo 只弹 Toast。产品化应清空 photoCount、Toast"上传成功"并跳转回首页/对应相册,让用户看到新照片已出现在瀑布流中——"上传成功可见"是上传类产品的最基本信任闭环。
一句话总结:上传页用"3×3 九宫格三态渲染 + 单选胶囊 + TextArea + 全宽按钮"完成了一次照片上传的最小完整流程。Grid + aspectRatio 的等方格子、photoCount 单一状态驱动的三态条件渲染、Stack 右上角 ✕ 角标、虚线"+"格,是照片上传类页面最经典的一组 ArkUI 手法——状态机设计的精妙,正是本页最值得反复咀嚼的工程价值。
最后补充一处架构观察:上传页的 events 数组(4 个活动名)与活动页、首页的数据源是同名同序的,这保证了"上传的照片归入哪个活动"在全 App 内语义一致。若产品化后统一抽一个 EventStore(全局数据仓库),上传页、活动页、首页都从它读取活动列表,即可彻底消除"三处硬编码活动名"的维护风险——新增一个活动只需在 Store 中加一条记录,三页同步更新。这种"先静态后抽离"的演进路径,正是小型 Demo 走向工程化项目的自然过程:先在各页写死数据快速验证 UI 与交互,确认无误后收敛到统一数据层,改动成本可控、风险最低。上传页作为数据生产端,是最先应该接入统一数据层的页面。
从验收视角回看四张实机截图:首屏(3/9 三态九宫格 + 关联活动胶囊 + TextArea)、添加照片(4/9、加号格后移)、删除照片(回 3/9、加号格前移)、上传提交("上传 3 张照片" Toast)——四个状态完整验证了 photoCount 状态机"增/删/提交"三个方向的流转正确性,以及计数、格子形态、Toast 文案三者始终一致的状态约束。这也说明:状态驱动的页面,验收的重点不是"每个界面长什么样",而是"每个状态转换是否自洽"——本页的验证方法(逐一触发增删、核对计数)正是这种思路的最小实践。 整体而言,本页以三态状态机为核心,把照片选择的最小流程完整落地,配合四张实机截图的增删验证,为上传类页面提供了高完成度的参考实现。 若再接入相册选择器与上传引擎,即可无缝升级为生产级功能,本页已为此预留全部扩展点。 这条从原型到生产的演进路径,正是本页最具参考价值的部分。

更多推荐





所有评论(0)