从零构建鸿蒙 AR 饮食识别 App:ArkTS 严格模式实战指南

从零构建鸿蒙 AR 饮食识别 App:ArkTS 严格模式实战指南
作者:duluo
平台:HarmonyOS Next (API 24)
语言:ArkTS
字数:约 10,000 字
一、项目背景与动机
1.1 为什么做 AR 饮食识别?
在健康意识日益增强的今天,人们越来越关注日常饮食的营养摄入。然而,大多数人并不清楚自己吃的食物含有多少热量、蛋白质、脂肪等营养成分。传统的做法是手动查找食物营养表或使用手机 App 搜索,但操作繁琐、体验不佳。
AR(增强现实)技术提供了一种更直观的解决方案:打开相机对准食物,系统自动识别并叠加显示营养信息。这种"所见即所得"的交互方式极大降低了用户的操作门槛。
1.2 为什么选择鸿蒙平台?
HarmonyOS Next(API 24)是华为推出的全场景分布式操作系统,具有以下优势:
- ArkTS 语言:基于 TypeScript 的声明式 UI 框架,与前端开发者技能高度匹配
- 强大的多媒体能力:原生支持相机、图像处理、XComponent 等能力
- 端侧 AI 能力:支持 MindSpore Lite 等端侧推理框架
- 一次开发多端部署:手机、平板、车机等多设备共享代码
1.3 项目目标
构建一个具备以下功能的 AR 饮食识别 App:
- 相机扫描:模拟 AR 相机取景界面
- 食物识别:通过关键词或模拟 AI 识别食物
- 营养展示:展示热量、蛋白质、脂肪、碳水、纤维等营养数据
- 饮食记录:记录每日摄入,按餐次分类
- 数据持久化:使用 Preferences 存储用户数据
二、技术架构与设计
2.1 整体架构
┌─────────────────────────────────────────────┐
│ UI 层 (pages) │
│ Index │ FoodDetail │ DietHistory │ Settings │
├─────────────────────────────────────────────┤
│ 组件层 (components) │
│ NutritionCard │ ScanResultPanel │
├─────────────────────────────────────────────┤
│ 模型层 (model) │
│ FoodData │ RecognitionService │
├─────────────────────────────────────────────┤
│ 系统能力 (kits) │
│ @kit.ArkUI │ @kit.ArkData │
└─────────────────────────────────────────────┘
2.2 数据流设计
用户操作 → 组件事件 → @State 状态变更 → UI 自动更新
↓
RecognitionService
↓
FoodDatabase (本地内存)
↓
DietRecordManager (持久化)
2.3 关键设计决策
| 决策 | 选择 | 理由 |
|---|---|---|
| 状态管理 | @State + @Link | ArkTS 原生响应式方案 |
| 路由 | router.pushUrl | 官方推荐,支持页面栈 |
| 数据持久化 | Preferences | 轻量级 KV 存储,适合配置和简单记录 |
| 单例模式 | 模块级变量 | 避免 static 方法中 this 问题 |
| 食物数据库 | 内存数组 | 28 种食物,无需 SQLite 的开销 |
三、ArkTS 严格模式深度解析
这是本博客最核心的部分。ArkTS 是 TypeScript 的超集,但在 API 24 中引入了严格模式,对语法有诸多限制。
3.1 Import 语法
错误写法 ❌
import router from '@kit.ArkUI';
正确写法 ✅
import { router } from '@kit.ArkUI';
原因:@kit.ArkUI 是一个模块命名空间,它没有 default 导出。必须使用具名导入语法。
3.2 内联对象类型禁止
错误写法 ❌
private categories: { key: FoodCategory; emoji: string }[] = [];
正确写法 ✅
// 先定义接口
export interface CategoryOption {
key: FoodCategory;
emoji: string;
}
// 再使用
private categories: CategoryOption[] = [];
错误写法 ❌
ForEach(items, (cat: { key: FoodCategory; emoji: string }) => { ... })
正确写法 ✅
ForEach(items, (cat: CategoryOption) => { ... })
原因:ArkTS 严格模式禁止在类型注解位置使用对象字面量。所有复合类型必须有明确的接口或类声明。
3.3 对象字面量必须显式类型标注
错误写法 ❌
this.categories = [
{ key: FoodCategory.FRUIT, emoji: '🍎' },
{ key: FoodCategory.VEGETABLE, emoji: '🥬' },
];
正确写法 ✅
this.categories = [
{ key: FoodCategory.FRUIT, emoji: '🍎' } as CategoryOption,
{ key: FoodCategory.VEGETABLE, emoji: '🥬' } as CategoryOption,
];
原因:编译器无法从数组字面量推断元素类型时必须提供显式 as 转换。这与 TypeScript 的类型推断行为不同。
3.4 静态方法中的 this
错误写法 ❌
export class FoodDatabase {
private static instance: FoodDatabase;
static getInstance(): FoodDatabase {
if (!this.instance) { // ❌ 静态方法中不能使用 this
this.instance = new FoodDatabase();
}
return this.instance;
}
}
正确写法 ✅
let foodDb: FoodDatabase | null = null;
export class FoodDatabase {
static getInstance(): FoodDatabase {
if (foodDb === null) {
foodDb = new FoodDatabase();
}
return foodDb as FoodDatabase;
}
}
原因:ArkTS 严格模式不支持在静态方法中使用 this 关键字。单例模式必须使用模块级变量替代静态属性。
3.5 解构赋值禁止
错误写法 ❌
const { calories, protein } = food.nutrition;
正确写法 ✅
const calories = food.nutrition.calories;
const protein = food.nutrition.protein;
原因:ArkTS 不支持解构赋值语法,需要逐行声明变量。
3.6 展开运算符限制
错误写法 ❌
const newConfig = { ...oldConfig, enabled: true };
正确写法 ✅
const newConfig = {
calories: oldConfig.calories,
protein: oldConfig.protein,
enabled: true,
} as ConfigType;
原因:对象展开运算符(spread operator)在 ArkTS 中仅支持数组类型,对象展开不被支持。
3.7 Array.from 不支持
错误写法 ❌
const keys = Array.from(map.keys());
正确写法 ✅
const keys: string[] = [];
map.forEach((_val: ValueType, key: string) => {
keys.push(key);
});
原因:Array.from() 在 ArkTS 中不可用,必须使用 forEach 或其他循环方式。
3.8 @Builder 中禁止变量声明
错误写法 ❌
@Builder
MySection() {
const hintText = '提示文字'; // ❌ @Builder 中不能有声明
if (hintText.length > 0) {
Text(hintText)
}
}
正确写法 ✅
// 方式一:使用 @State 变量
@State hintText: string = '提示文字';
@Builder
MySection() {
if (this.hintText.length > 0) {
Text(this.hintText)
}
}
// 方式二:拆分为多个 @Builder
@Builder
HintWhenScanning() {
Text('对准食物,自动识别中...')
}
@Builder
HintWhenIdle() {
Text('点击识别按钮开始扫描')
}
原因:@Builder 装饰的方法体中只能包含 UI 组件语法,不允许声明语句。条件逻辑需要使用 if 表达式而非变量。
3.9 动态 import 禁止
错误写法 ❌
import('../model/FoodData').then(mod => { ... });
正确写法 ✅
// 静态导入
import { FoodDatabase } from '../model/FoodData';
const db = FoodDatabase.getInstance();
db.searchFood('苹果');
原因:ArkTS 不支持动态 import() 语法,所有模块导入必须在文件顶部静态声明。
3.10 any/unknown 类型禁止
错误写法 ❌
const params: any = router.getParams();
正确写法 ✅
const params = router.getParams() as Record<string, Object>;
if (params !== undefined && params['foodId'] !== undefined) {
this.foodId = Number(params['foodId']);
}
原因:ArkTS 严格模式禁止使用 any 和 unknown 类型,必须使用明确的类型注解。
3.11 属性名与组件方法冲突
错误写法 ❌
@Component
struct Settings {
@State height: number = 170; // ❌ height 与组件属性冲突
}
正确写法 ✅
@Component
struct Settings {
@State userHeight: number = 170; // ✅ 避免冲突
}
原因:height、width 等是 CommonAttribute 的方法名,不能用作 @State 变量名。
3.12 AlertDialog 按钮 API
API 24 的 AlertDialog 按钮使用 primaryButton + confirm 字段:
AlertDialog.show({
title: '确认',
message: '确定执行此操作?',
primaryButton: {
value: '取消',
action: () => {}
},
confirm: {
value: '确认',
action: () => { this.doSomething(); }
}
});
注意:按钮的显示文本字段是 value(不是 text),color 字段也不被支持。
四、食物数据库设计
4.1 数据结构
食物数据库是 App 的核心数据。我设计了以下数据模型:
export interface NutritionInfo {
calories: number;
protein: number;
fat: number;
carbohydrate: number;
fiber: number;
sugar?: number;
sodium?: number;
}
export interface FoodItem {
id: number;
name: string;
category: FoodCategory;
nutrition: NutritionInfo;
servingSize: number;
servingUnit: string;
healthRating: HealthRating;
tags: string[];
emoji: string;
}
4.2 工厂函数模式
为了规避对象字面量语法问题,我采用了工厂函数来创建 FoodItem:
function makeFood(
id: number, name: string, cat: FoodCategory,
cal: number, pro: number, fat: number, carb: number, fiber: number,
size: number, unit: string, rating: HealthRating,
tags: string[], emoji: string, sugar?: number, sodium?: number
): FoodItem {
return {
id, name, category: cat,
nutrition: { calories: cal, protein: pro, fat, carbohydrate: carb, fiber, sugar, sodium } as NutritionInfo,
servingSize: size, servingUnit: unit, healthRating: rating, tags, emoji,
} as FoodItem;
}
这样每个食物条目只需要一行调用,避免了大量重复的对象字面量代码:
makeFood(101, '苹果', FoodCategory.FRUIT, 52, 0.3, 0.2, 14, 2.4, 200, 'g', HealthRating.EXCELLENT, ['高纤维', '维生素C'], '🍎'),
makeFood(102, '香蕉', FoodCategory.FRUIT, 89, 1.1, 0.3, 23, 2.6, 120, 'g', HealthRating.GOOD, ['高钾'], '🍌', 12),
4.3 数据库访问层
let foodDb: FoodDatabase | null = null;
export class FoodDatabase {
private items: FoodItem[] = FOOD_LIST;
static getInstance(): FoodDatabase {
if (foodDb === null) {
foodDb = new FoodDatabase();
}
return foodDb as FoodDatabase;
}
getAll(): FoodItem[] { return this.items; }
getById(id: number): FoodItem | undefined {
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].id === id) { return this.items[i]; }
}
return undefined;
}
search(query: string): FoodItem[] {
const q = query.toLowerCase();
const r: FoodItem[] = [];
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].name.toLowerCase().includes(q)) { r.push(this.items[i]); }
}
return r;
}
}
关键点:
- 使用模块级变量
foodDb替代静态属性 getInstance()方法中不使用this- 所有方法使用常规
for循环,避免filter/find等可能不受支持的操作
五、AR 相机界面实现
5.1 布局结构
AR 相机界面使用 Stack 布局实现三层叠加:
Stack (全屏)
├── 背景层 (深色背景模拟相机取景器)
├── 扫描框层 (四角边框 + 扫描线)
├── 顶层 (顶部状态栏 + 底部控制按钮)
└── 底部面板 (识别结果)
5.2 扫描框实现
// 扫描线动画
if (this.isScanning) {
Column() {
Column().width('70%').height(2).backgroundColor('#4DEE7B')
.shadow({ radius: 8, color: '#66EE7B' })
}.width('100%').height('60%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.clip(true)
// 四角边框
Column() { Column().width(40).height(3).backgroundColor('#4DEE7B').borderRadius(2) }
.position({ x: '15%', y: '15%' })
Column() { Column().width(40).height(3).backgroundColor('#4DEE7B').borderRadius(2) }
.position({ right: '15%', top: '15%' })
// ...
}
5.3 扫描逻辑
toggleScan(): void {
this.isScanning = !this.isScanning;
if (this.isScanning) {
setTimeout(() => {
if (this.isScanning) {
this.currentResult = this.recognition.recognizeRandom();
this.isScanning = false;
}
}, 2000);
}
}
扫描按钮点击后:
- 显示扫描动画(绿色边框 + 提示文字)
- 2 秒后模拟识别完成
- 隐藏扫描框,显示识别结果面板
5.4 底部结果面板
识别完成后,底部滑出操作面板:
┌─────────────────────────────────────┐
│ 🍎 苹果 ✕ │
│ 水果 │
│ 🔥 52 kcal 💪 0.3g 蛋白 │
│ [-] 1 份 [+] [午餐 ▾] │
│ [📊 详情] [➕ 添加到记录] │
└─────────────────────────────────────┘
六、状态管理与组件通信
6.1 @Link 双向绑定
父子组件通过 @Link 实现双向数据绑定:
// 父组件
@Component
struct Index {
@State currentResult: RecognitionResult | null = null;
build() {
ScanResultPanel({ result: this.currentResult })
}
}
// 子组件
@Component
export struct ScanResultPanel {
@Link result: RecognitionResult | null; // ✅ 与父组件共享状态
build() {
if (this.result !== null) {
Button() { Text('✕') }
.onClick(() => { this.result = null; }) // ✅ 修改会同步到父组件
}
}
}
注意:API 24 中 @Link 的自动绑定机制已经完善,父组件传递 @State 变量时不需要 $ 前缀,框架会自动处理双向绑定。
6.2 @Prop 单向数据流
// 营养卡片 - 只读展示
@Component
export struct NutritionCard {
@Prop nutrition: NutritionInfo = { ... } as NutritionInfo;
@Prop rating: HealthRating = HealthRating.MODERATE;
@Prop compact: boolean = false;
// 子组件不能修改 @Prop 值
}
6.3 页面间导航
import { router } from '@kit.ArkUI';
// 跳转到详情页
router.pushUrl({ url: 'pages/FoodDetail' });
// 携带参数
router.pushUrl({ url: 'pages/FoodDetail' });
// 在目标页面读取
const params = router.getParams() as Record<string, Object>;
路由配置:所有页面必须在 main_pages.json 中注册:
{
"src": [
"pages/Index",
"pages/FoodDetail",
"pages/DietHistory",
"pages/Settings"
]
}
七、数据持久化
7.1 Preferences 使用
import { preferences } from '@kit.ArkData';
// 初始化
const pref = await preferences.getPreferences(context, 'ar_diet_settings');
// 写入
await pref.put('calorie_goal', 2000);
await pref.flush(); // ✅ 必须调用 flush 确保写入
// 读取
const goal = await pref.get('calorie_goal', 2000) as number;
7.2 注意事项
- 异步操作:所有 Preferences 操作都是异步的,需要使用
async/await - flush() 必须调用:
put()后必须调用flush()才能持久化 - 类型转换:
get()返回ValueType,需要as转换为具体类型
async loadSettings(): Promise<void> {
try {
this.pref = await preferences.getPreferences(context, 'ar_diet_settings');
this.dailyCalorieGoal = (await this.pref.get('calorie_goal', 2000)) as number;
this.enableAutoCapture = (await this.pref.get('auto_capture', true)) as boolean;
} catch (err) {
console.error('[Settings] load error: ' + JSON.stringify(err));
}
}
八、常见编译错误与解决方案
8.1 错误速查表
| 错误码 | 错误信息 | 原因 | 解决方案 |
|---|---|---|---|
| 10311006 | ‘default’ is not exported from Kit | import 语法错误 | 使用 import { X } from '@kit.XX' |
| 10605040 | Object literals cannot be used as type declarations | 内联对象类型 | 定义 interface |
| 10605038 | Object literal must correspond to class/interface | 未标注类型的对象 | 使用 as InterfaceType |
| 10605093 | Using “this” inside stand-alone functions | 静态方法中 this | 模块级变量替代 |
| 10605074 | Destructuring variable declarations | 解构赋值 | 逐行赋值 |
| 10605099 | Spread only for arrays | 对象展开 | 手动合并 |
| 10905209 | Only UI component syntax can be written here | @Builder 中有声明 | 移到 @State 或拆 @Builder |
8.2 调试技巧
- 查看完整错误栈:在 DevEco Studio 的 Terminal 中运行
hvigorw --stacktrace - 增量编译:首次报错后,修改文件会自动触发增量编译
- 清理缓存:
hvigorw clean可以清理构建缓存 - 预览 vs 真机:预览模式编译更严格,先用预览验证语法
九、性能优化建议
9.1 避免不必要的渲染
// ❌ 每次点击都会触发整个 List 重渲染
ForEach(this.items, (item: FoodItem) => { ... })
// ✅ 小列表使用 ForEach 没问题,大列表考虑 LazyForEach
// API 24 中 LazyForEach 需要实现 IDateSource
9.2 合理使用 @State
// ❌ 频繁修改的状态会导致大量重绘
@State counter: number = 0;
setInterval(() => { this.counter++; }, 16); // 60fps 更新
// ✅ 使用普通变量管理局部状态
private realCounter: number = 0;
9.3 动画性能
// ✅ 使用系统动画
.animation({ duration: 300, curve: Curve.EaseInOut })
// ✅ 使用 transition 做页面切换
.transition({ type: TransitionType.Push, duration: 300 })
十、项目总结与展望
10.1 已实现功能
- ✅ AR 风格相机扫描界面
- ✅ 28 种常见食物数据库
- ✅ 模拟食物识别
- ✅ 营养数据展示
- ✅ 饮食记录追踪
- ✅ 设置页面
- ✅ 数据持久化
10.2 可扩展方向
- 真实 AI 识别:接入 MindSpore Lite 端侧模型,实现摄像头实时识别
- 相机预览:使用
@ohos.multimedia.camera+ XComponent 实现真实相机预览 - 营养数据库扩展:接入开源食物数据库(如 USDA FoodData Central)
- 饮食报告:周/月饮食分析报告,营养摄入达标率
- 社区功能:用户分享饮食记录、食物图片
10.3 端侧 AI 集成方案
// 伪代码 - MindSpore Lite 集成
import { mindSporeLite } from '@kit.AIKit';
async function recognizeFood(image: Image): Promise<FoodItem> {
const model = await mindSporeLite.loadModel('food_model.ms');
const input = model.createInput(image);
const output = await model.predict(input);
const foodId = output.getData()[0];
return FoodDatabase.getInstance().getById(foodId);
}
更多推荐



所有评论(0)