HarmonyOS 6 一次开发多端部署实战:用「鱼缸记」做智能家居养护 App
HarmonyOS 6 一次开发多端部署实战:用「鱼缸记」做智能家居养护 App
适用活动:鸿蒙生态赋能资源丰富度建设(2026-07-15 ~ 2026-12-31)
主题方向:多设备应用开发 + 应用性能优化 + 上架审核
工程版本:HarmonyOS 6.1.1(API 24),包名com.personal.yugangji
设备类型:phone/tablet/2in1
水族养护是典型的「家里用、随时看、不能断网」场景。用户打开 App 的第一诉求不是云端同步,而是:缸里养了什么、今天喂没喂、水质看起来正不正常、勾几个症状马上拿到可执行步骤。
「鱼缸记」按这个场景做成 纯单机 HarmonyOS 应用:不建业务服务器、不登录、不投广告。一套 ArkTS 代码覆盖手机、平板和 2in1,窗口变宽时发生结构变化,而不是把手机稿等比放大。本文按真实工程拆四件事:分层、断点、场景动画性能、上架审核三表对齐。
文中截图均来自本机 HarmonyOS 6.1.1 模拟器实跑(手机 + 三折叠展开),不是效果图。

图 1 手机竖屏首页。问候语、25℃ / pH、加鱼 / 喂食 / 灯光都叠在缸景上,没有再套一层底栏。
1. 为什么这个场景适合鸿蒙,而不是再套一层云
智能家居类 App 最容易踩两个坑:
- 把记录工具做成商城。卖鱼、卖药、包活承诺,审核和内容合规都会卡住。
- 把「拍照」做成「云端诊断」。没有深度合成/生成式 AI 资质,商店简介却写 AI 识别,提审几乎必打回。
鱼缸记把产品边界写死:
| 做 | 不做 |
|---|---|
| 演示缸、图鉴、混养提示、日程、打卡规则方案 | 商城、直播、包活、兽医处方 |
| 照片仅本机对比,拒绝相册仍可打卡 | 后台偷拍、上传照片、生成式文案 |
| Preferences 落本地,关闭系统云备份 | 账号墙挡住帮助 / 隐私政策 |
审核主路径也按飞行模式设计:
演示缸 → 日程看到喂食/换水/液肥 → 打卡勾症状 → 立刻显示
「立刻做 / 本周 / 风险」预置方案(规则表,不是大模型)
这不是功能缩水,是把「一次开发多端部署」的代码量和「能过审」绑在同一条需求上。
2. 工程骨架:app / business / foundation
目标 SDK:6.1.1(24)。模块分层:
app → business → component → foundation
page / view → view_model → repository → model
| 层 | 职责 | 禁止 |
|---|---|---|
app/pages/Index.ets | Navigation 栈、窗口宽度、毛玻璃 Sheet 编排 | 直接读写 Preferences |
business/home pedia checkin schedule mine | 页面与规则 | 同级模块互相 import |
component/glass | 可复用毛玻璃面板 | 夹带业务字段 |
foundation | 断点、Preferences、资源图 | 反向依赖 Page |
根页面用 Navigation + onAreaChange 拿窗口宽度,所有断点判断都吃同一个 winW,避免每个子页各自 display.getDefaultDisplaySync() 各算一套。
Navigation(this.pathInfos) {
Stack() {
HomePage({ vm: this.homeVm, winW: this.winW, onOpenSheet: (id: string) => {
this.openSheet(id);
} })
if (this.sheet.length > 0) {
GlassPanel({ title: this.sheetTitle(), winW: this.winW, onClose: () => {
this.closeSheet();
} }) {
this.sheetBody()
}
}
}
}
.hideTitleBar(true)
.mode(NavigationMode.Stack)
.onAreaChange((_o: Area, area: Area) => {
this.winW = area.width as number;
})
窗口宽度是「一次开发多端」的唯一真值。折叠展开、分屏、平板横屏,本质都是 winW 跨过 600 / 840。
3. 多设备:断点要改结构,不要改缩放
官方自适应常用档:sm 320、md 600、lg 840。工程里收成工具类:
export class Breakpoint {
static readonly SM: number = 320;
static readonly MD: number = 600;
static readonly LG: number = 840;
static isMd(width: number): boolean {
return width >= Breakpoint.MD;
}
static isLg(width: number): boolean {
return width >= Breakpoint.LG;
}
static collectionColumns(width: number, base: number): number {
if (width >= Breakpoint.LG) {
return base + 2;
}
if (width >= Breakpoint.MD) {
return base + 1;
}
return base;
}
}
鱼缸记没有「所有设备同一套底栏 + width('100%')」。各页结构变化如下。
3.1 图鉴:手机栈式,平板左右分栏
md 及以上,图鉴 Sheet 变成左列表右详情,选中态用同一个 selectedId,不再 push 一层页面:
} else if (Breakpoint.isMd(this.winW)) {
Row() {
PediaListPage({
vm: this.pediaVm,
winW: this.winW,
selectedId: this.selectedId,
embedded: true,
onOpen: (id: string) => {
this.selectedId = id;
}
})
.layoutWeight(1)
SpeciesDetailPage({
vm: this.pediaVm,
speciesId: this.selectedId,
showBack: false,
embedded: true
})
.layoutWeight(1)
}
.width('100%')
.height('100%')
}
窄屏仍是列表 → 详情两步。这是集合页的标准响应式模式:N 列变 N+1,详情从「下一页」变成「右侧栏」。三折叠展开后实拍如下:左网格选中霓虹灯鱼,右侧立刻出温度 / pH / 体型 / 混养注意,不必再 push 一层页面。

图 2 三折叠展开。图鉴 Sheet 左右分栏,选中态用同一 selectedId。
3.2 毛玻璃面板:大屏变宽,而不是铺满
GlassPanel 用 backdropBlur(32) 做系统级模糊,宽度按断点切换,并加 maxWidth: 880,避免 2in1 上出现一条横向拉满的「假平板」对话框。
.width(Breakpoint.isLg(this.winW) ? '72%' : '92%')
.height(Breakpoint.isLg(this.winW) ? '84%' : '78%')
.constraintSize({ maxWidth: 880 })
.backgroundColor($r('app.color.frost_bg'))
.border({ width: 1, color: $r('app.color.frost_border') })
.borderRadius(28)
.backdropBlur(32)
首页状态卡、右侧快捷栏、更多功能菜单走同一套 frosted token,图标优先 SymbolGlyph($r('sys.symbol.xxx')),减少自绘 SVG 在深色水底上的糊边。
3.3 首页安全区与留白
首页缸景全幅铺开,状态卡贴在底部,必须吃系统避让:
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.padding({
left: Breakpoint.isLg(this.winW) ? 36 : 18,
right: Breakpoint.isLg(this.winW) ? 36 : 18,
top: 18,
bottom: 18
})
横屏、折叠、平板分屏时,改的是 padding 和面板占比,不改卡片圆角和字体 token。几何锁定、结构可变,这是「一套视觉合同、多套布局」的落地方式。
module.json5 声明三类设备,避免只标 phone 导致平板分发被拒:
"deviceTypes": ["phone", "tablet", "2in1"]
4. 场景动画:为什么量产不用每帧 Canvas 清屏
缸里的鱼要动,这是产品记忆点。工程里曾经写过 Tank3DRenderer:CanvasRenderingContext2D 每 30ms clearRect 再画渐变、水草、气泡、鱼。量产首页没有挂它。
量产路径是 TankAquarium:水草和鱼都是 Image + position,ViewModel 按窗口矩形做边界反弹。
aboutToAppear(): void {
this.timer = setInterval(() => {
this.vm.tickSwim(this.areaW, this.areaH);
}, 48);
}
aboutToDisappear(): void {
if (this.timer >= 0) {
clearInterval(this.timer);
this.timer = -1;
}
}
游动逻辑把鱼限制在水面以下、底砂以上,碰到左右边界改 vx 并翻转:
tickSwim(width: number, height: number): void {
if (width < 40 || height < 80) {
return;
}
const left = 12;
const right = width - 12;
const top = height * 0.16;
const bottom = height * 0.68;
for (const fish of this.fishes) {
fish.x = fish.x + fish.vx;
fish.y = fish.y + Math.sin(fish.x / 36) * 0.35;
if (fish.x + fish.w > right) {
fish.vx = -Math.abs(fish.vx);
fish.flip = true;
fish.x = right - fish.w;
}
// ...
}
}
对应的性能取舍:
| 方案 | 帧成本 | 问题 |
|---|---|---|
| Canvas 全场景重绘(实验代码) | 每帧清屏 + 渐变 + 多条正弦波 | 沙石用 Math.random() 每帧重生,闪烁且吃 CPU |
| Image 精灵 + 48ms tick(量产) | 只改 x/y/flip | 鱼数量要封顶,水草不参与水平位移 |
成员重建时直接砍数量:水草最多 6,鱼最多 12。ForEach 用稳定 id 做 key,装饰层 HitTestMode.None,点击穿透到下面的喂食 / 灯光按钮。
ForEach(this.vm.fishes, (item: SwimSprite) => {
Image(item.image)
.width(item.w)
.height(item.h)
.scale({ x: item.flip ? -1 : 1, y: 1 })
.position({ x: item.x, y: item.y })
.hitTestBehavior(HitTestMode.None)
}, (item: SwimSprite) => item.id)
48ms 约 21fps,对「鱼在游」足够,比 30ms 全 Canvas 更省。组件销毁必须 clearInterval,Sheet 反复打开时否则会叠多个定时器。
如果以后要做更接近 3DGS / 端侧重建的缸景,正确入口是独立渲染表面 + 固定随机种子,而不是在 drawScene() 里每帧 Math.random()。鱼缸记把实验渲染器和量产精灵层拆开,就是为了不把帧预算绑死在首页。
5. 数据面:同步 Preferences,异步只做启动灌缓存
单机 App 最怕两件事:首屏空数据、打卡后杀进程记录没了。
LocalStore 启动时把常用键一次灌进内存 Map,读路径全同步;写路径先改缓存,再 putSync + flushSync,失败再走 Promise,避免把 UI 线程卡在首次 getPreferences。
static get(key: string, fallback: string): string {
const hit = LocalStore.cache.get(key);
if (hit !== undefined) {
return hit;
}
return fallback;
}
static set(key: string, value: string): void {
LocalStore.cache.set(key, value);
const p = LocalStore.pref;
if (p === undefined) {
return;
}
try {
p.putSync(key, value);
p.flushSync();
} catch (_err) {
p.put(key, value).then(() => {
p.flush().catch(() => {});
}).catch(() => {});
}
}
云备份关闭,卸载即清,和隐私政策一致:
{
"allowToBackupRestore": false
}
喂食、开灯、换水只在本机打时间戳,文案写明「不控制喂食器 / 灯具 / 过滤硬件」。IoT 控制是下一阶段,不能在 v1 简介里写成「智能硬件联动」。
6. 打卡方案:规则表先于任何「识别」
打卡是审核主路径,必须在拒绝相册时也能走完。CheckinRepository.match 按症状 key 返回三段固定文案,并强制「仅供参考 / 不替代兽医」。
match(selected: string[]): CarePlan {
let now = '观察水质与温度,记录今日状态。';
let week = '按日程完成喂食、换水 1/3 与水草液肥。';
let risk = '建议记录温度、pH、氨氮/亚硝酸盐和溶氧;异常或持续恶化时联系水生动物兽医。';
if (selected.indexOf('white') >= 0) {
now = '先记录白点位置与数量,检查温度和水质;不要仅凭照片自行用药或升温。';
risk = '白点可能有多种原因,治疗方式依病因和物种而异;本应用不作诊断。';
} else if (selected.indexOf('air') >= 0) {
now = '立即检查增氧、过滤和水面扰动,并检测溶氧、氨氮和亚硝酸盐。';
}
if (selected.length === 0) {
now = '未勾选症状:维持日常观察即可。';
}
return new CarePlan(now, week, risk);
}
相册走 PhotoViewPicker,取消或抛错只改 hint,不阻断 generate()。勾「白点」后点「查看养护建议」,立刻做 / 本周 / 风险三张卡会写进本机日志,不经过网络。

图 3 宽屏打卡。左侧照片可跳过,右侧已给出「立刻做 / 本周 / 风险」,文案写明不作诊断。
工程里另有一个 端侧颜色直方图匹配(FishRecognizer),用 ImageKit 把 PixelMap 读成 Uint8Array 后对主色做最近邻,不上传、不调云、不生成文字。它不是 Harmony Intelligence 生成式能力,也不能写进商店简介当「AI 识鱼」。
上架时更容易被打回的,不是「有没有识别」,而是 三表不一致:
| 表 | 必须互相看见 |
|---|---|
module.json5 权限 | 相机 / 读图是 inuse,有 reason 文案 |
| 应用内隐私政策 | 写清存什么、不上传、不做云端分析 |
| 商店简介 / 截图 | 不写「AI 诊断」「到点系统通知」(当前版本日程只在 App 内展示到期项) |
鱼缸记隐私页原文强调:不创建账号、不连接业务服务器、选图可拒绝、养护建议仅供参考。帮助页给出飞行模式逐步操作。审核员按帮助页点一遍,就能完成主路径。
7. 上架审核清单(个人开发者可执行)
结合本工程已经踩过的点,提审前至少核对:
- 单机声明为真:
requestPermissions不要出现INTERNET;不要在飞行模式弹联网错误。 - 演示数据可走完:未授权相机也能打开演示缸、图鉴详情、打卡方案。
- 禁止项不上屏:无价格、无购买、无「保证包活」、无登录墙挡住帮助。
- 权限最小化:照片选择失败不崩溃;不在后台申请持续相机。
- 备份策略与文案一致:已关云备份,就不要写「换机自动恢复」。
- 多设备截图是实机:手机竖屏、折叠展开、平板分栏至少各一张,不要用 AI 效果图冒充商店图。
- 通知能力别超卖:没申请通知权限,就不要在简介写「到点推送」。
- 签名材料不进仓库:发布证书、Profile、密码只放本机或密钥库。
AGC 勾选「单机 APP」时,审核员会用飞行模式验证。把验收步骤写成帮助页,等于把审核脚本内置进产品。
8. 可以复用的结论
- 窗口宽度是多端唯一真值。
onAreaChange一次采集,Sheet、分栏、字号、面板占比全部消费winW。 - 断点必须改信息架构。 图鉴 md+ 左右分栏,毛玻璃 lg 限宽,而不是所有设备一张手机卡片拉宽。
- 视觉特效让给系统。
backdropBlur+SymbolGlyph比自绘毛玻璃和 PNG 图标更稳。 - 动画先封顶再谈效果。 精灵位移 + 稳定 key + 销毁停表,比每帧 Canvas 清屏更适合首页常驻。
- 审核路径等于产品主路径。 规则表出方案、演示缸、应用内隐私政策,比事后补「飞行模式也能用」成本低。
鱼缸记是垂直行业里很小的一个切口:把「记缸」做成可上架的 HarmonyOS 6 应用。多设备、性能和审核不是三篇分开的文章,而是同一套分层在三个方向上的投影。
更多推荐


所有评论(0)