在这里插入图片描述

从零构建鸿蒙 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:

  1. 相机扫描:模拟 AR 相机取景界面
  2. 食物识别:通过关键词或模拟 AI 识别食物
  3. 营养展示:展示热量、蛋白质、脂肪、碳水、纤维等营养数据
  4. 饮食记录:记录每日摄入,按餐次分类
  5. 数据持久化:使用 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 严格模式禁止使用 anyunknown 类型,必须使用明确的类型注解。

3.11 属性名与组件方法冲突

错误写法 ❌

@Component
struct Settings {
  @State height: number = 170;  // ❌ height 与组件属性冲突
}

正确写法 ✅

@Component
struct Settings {
  @State userHeight: number = 170;  // ✅ 避免冲突
}

原因heightwidth 等是 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);
  }
}

扫描按钮点击后:

  1. 显示扫描动画(绿色边框 + 提示文字)
  2. 2 秒后模拟识别完成
  3. 隐藏扫描框,显示识别结果面板

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 注意事项

  1. 异步操作:所有 Preferences 操作都是异步的,需要使用 async/await
  2. flush() 必须调用put() 后必须调用 flush() 才能持久化
  3. 类型转换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 调试技巧

  1. 查看完整错误栈:在 DevEco Studio 的 Terminal 中运行 hvigorw --stacktrace
  2. 增量编译:首次报错后,修改文件会自动触发增量编译
  3. 清理缓存hvigorw clean 可以清理构建缓存
  4. 预览 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 可扩展方向

  1. 真实 AI 识别:接入 MindSpore Lite 端侧模型,实现摄像头实时识别
  2. 相机预览:使用 @ohos.multimedia.camera + XComponent 实现真实相机预览
  3. 营养数据库扩展:接入开源食物数据库(如 USDA FoodData Central)
  4. 饮食报告:周/月饮食分析报告,营养摄入达标率
  5. 社区功能:用户分享饮食记录、食物图片

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);
}

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐