在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

鸿蒙NEXT实战:基于ArkTS构建个性化推荐引擎(API 24)

作者:duluo
开发环境:DevEco Studio 6.1 / HarmonyOS NEXT API 24
核心语言:ArkTS + ArkUI 声明式UI框架
项目地址:[demo01 - 个性化推荐引擎]


一、前言

1.1 为什么选择推荐引擎这个主题?

在信息爆炸的时代,推荐系统无处不在——短视频、电商、新闻、音乐……每一款成功的互联网产品背后,都有一个精准的推荐引擎在支撑着用户体验。推荐系统的核心价值在于:在海量信息中,为用户筛选出最有可能感兴趣的内容,降低用户的信息获取成本。

然而,传统的推荐系统开发往往需要后端服务、数据库、算法模型等复杂基础设施。本文希望通过一个纯客户端的推荐引擎App,展示如何在鸿蒙NEXT API 24平台上,利用ArkTS和ArkUI完整实现推荐系统的核心逻辑——包括用户画像构建、内容评分排序、个性化推荐列表生成、用户交互反馈闭环等关键环节。

1.2 App概览

个性化推荐引擎App是一个纯本地运行的智能推荐系统,核心功能包括:

  • 个性化推荐首页:基于用户兴趣标签和交互行为,为16部作品(电影/图书/剧集)生成个性化推荐列表
  • 多维度评分算法:标签匹配(权重+10/项)、评分加权(评分×5)、热度加权(热度÷100)、用户反馈(喜欢+30、不喜欢-50)
  • 兴趣偏好设置:15个可选标签自由组合,实时影响推荐结果
  • 探索发现:按类型(电影/图书/剧集)筛选,支持关键词搜索
  • 详情分析页:展示推荐评分构成、推荐理由、关联推荐(喜欢这个的人也喜欢)
  • 交互反馈闭环:点赞/不感兴趣,系统实时调整推荐排序

1.3 技术栈

技术 版本/用途
操作系统 HarmonyOS NEXT 6.1(API 24)
开发工具 DevEco Studio 6.1
编程语言 ArkTS(基于TypeScript)
UI框架 ArkUI 声明式UI
构建工具 hvigorw
应用模型 Stage模型

二、推荐算法设计

2.1 算法总体架构

推荐引擎的核心是评分函数(Scoring Function),它综合多个维度的信号,为每个物品计算一个综合推荐分,然后按分数从高到低排序输出。

本App采用线性加权评分模型

Score(item) = TagMatchScore + RatingScore + PopularityScore + FeedbackScore

其中:

  • TagMatchScore:用户兴趣标签与物品标签的匹配度
  • RatingScore:物品的基础评分
  • PopularityScore:物品的热度(人气)
  • FeedbackScore:用户对该物品的历史交互反馈

为什么选择推荐引擎这个主题?

在信息爆炸的时代,推荐系统无处不在——短视频、电商、新闻、音乐……每一款成功的互联网产品背后,都有一个精准的推荐引擎在支撑用户体验。推荐系统的核心价值在于:在海量信息中,为用户筛选出最有可能感兴趣的内容,降低用户的信息获取成本。

传统推荐系统开发往往需要后端服务、数据库、算法模型等复杂基础设施。本文希望通过一个纯客户端的推荐引擎App,展示如何在鸿蒙NEXT API 24平台上,利用ArkTS和ArkUI完整实现推荐系统的核心逻辑——包括用户画像构建、内容评分排序、个性化推荐列表生成、用户交互反馈闭环等关键环节。

推荐系统的核心挑战

客户端推荐引擎与后端推荐系统面临不同的挑战。后端推荐系统通常拥有海量用户数据、丰富的特征工程和强大的计算资源,而客户端推荐引擎需要在有限的设备资源上运行,不能依赖网络请求,必须实现"开箱即用"的推荐体验。因此,本App选择了轻量级的线性加权模型,它不需要训练数据、不需要网络请求、不需要复杂的数学库支持,仅凭纯逻辑计算就能产生合理且可解释的推荐结果。

2.2 评分函数实现

scoreItem(item: Item): number {
  let score = 0

  // 1. 标签匹配:用户的每个偏好标签 +10分
  for (const tag of this.userTags) {
    if (item.tags.includes(tag)) {
      score += 10
    }
  }

  // 2. 评分加权:评分 × 5
  score += item.rating * 5

  // 3. 热度加权:热度 / 100
  score += item.热度 / 100

  // 4. 用户交互反馈
  if (this.likedItems.indexOf(item.id) >= 0) {
    score += 30  // 喜欢的物品加分
  }
  if (this.dislikedItems.indexOf(item.id) >= 0) {
    score -= 50  // 不喜欢的物品减分
  }

  return score
}

各维度权重的设计考量

  • 标签匹配(+10/项):用户明确选择的兴趣标签是最强的信号。如果一个物品匹配了用户3个标签,仅此项就贡献30分。权重值设定为10,既保证标签匹配在总分中占据显著地位,又不会完全淹没其他信号。

  • 评分加权(×5):物品的公共评分代表"大众口碑"。评分范围110,乘以5后贡献550分。一部9.5分的佳作在评分维度贡献47.5分,而一部8.0分的作品贡献40分。

  • 热度加权(÷100):物品的热度值(展现受欢迎程度)从几千到一万多不等。除以100使其贡献几十到一百多分,热度最高的作品会获得额外加成。

  • 用户反馈(+30/-50):用户明确点赞的物品获得30分加成;明确不喜欢的物品被扣除50分并可能被完全屏蔽。这种"惩罚远大于奖励"的设计,确保推荐引擎能快速过滤用户不感兴趣的内容。

为什么是线性加权?

在实际工业级推荐系统中,常用的是更复杂的模型——矩阵分解(Matrix Factorization)、因子分解机(FM)、深度学习模型(Wide & Deep、DIN等)。但对于一个客户端App来说,线性加权模型有不可替代的优势:

  1. 计算效率:无需模型加载和推理,O(n)复杂度即可完成排序
  2. 可解释性:每个维度的贡献清晰可见,用户可以理解"为什么推荐这个"
  3. 零冷启动成本:无需历史数据训练,用户初次使用即可获得合理推荐
  4. 代码量极小:几十行代码即可实现,维护成本极低
  5. 实时更新:用户一旦修改偏好或点击反馈,排序立即更新

2.3 标签关联推荐(“喜欢这个的人也喜欢”)

除了全局排序推荐,App还实现了基于标签重叠度的关联推荐算法:

getRelatedItems(item: Item): Item[] {
  let result: Item[] = []
  let scored: ScoredMatch[] = []
  for (const other of this.allItems) {
    if (other.id !== item.id && this.dislikedItems.indexOf(other.id) < 0) {
      let matchCount = 0
      for (const t of other.tags) {
        if (item.tags.includes(t)) matchCount++
      }
      if (matchCount > 0) {
        scored.push({ item: other, match: matchCount })
      }
    }
  }
  scored.sort((a, b) => b.match - a.match)
  for (let i = 0; i < Math.min(4, scored.length); i++) {
    result.push(scored[i].item)
  }
  return result
}

算法原理

这个函数的输入是一个物品,输出是与之"最相似"的至多4个其他物品。相似度通过标签重叠计数来衡量:两个物品共享的标签越多,它们就越相关。

例如,用户正在查看《星际穿越》(标签:科幻、冒险、剧情),系统会查找其他同样包含"科幻"“冒险”“剧情"标签的物品。《盗梦空间》共享"科幻"标签,匹配度+1;《流浪地球》共享"科幻”"冒险"两个标签,匹配度+2,排名更高。

这种基于内容的关联推荐(Content-based Recommendation)简单而有效,不需要用户行为数据就能产生合理的推荐结果。


三、应用架构设计

3.1 页面结构

App采用四页面单宿主架构,所有页面视图通过条件渲染切换,无路由跳转:

┌─────────────────────────────────────────┐
│               Stack 根容器                 │
│  ┌───────────────────────────────────┐   │
│  │      Column 主容器                  │   │
│  │  ├── TopBar() 顶部导航栏            │   │
│  │  ├── HomePage() / ExplorePage()    │   │
│  │  │   ├── DetailPage() / Profile()  │   │
│  │  └── (BottomBar 在 Stack 中叠加)    │   │
│  └───────────────────────────────────┘   │
│  ┌───────────────────────────────────┐   │
│  │  BottomBar() 底部导航(详情页隐藏)  │   │
│  └───────────────────────────────────┘   │
└─────────────────────────────────────────┘

四页面的职责划分

页面 核心职责 数据依赖
HomePage 个性化推荐列表展示 personalizedItems 计算属性
ExplorePage 分类浏览 + 搜索 filteredItems 计算属性
DetailPage 物品详情 + 评分分析 + 关联推荐 selectedItem 状态
ProfilePage 兴趣标签管理 + 交互统计 userTags/likedItems/dislikedItems

3.2 状态管理设计

整个App只用到了5个 @State 变量来驱动所有UI更新:

@State currentPage: 'home' | 'explore' | 'detail' | 'profile' = 'home'
@State selectedItem: Item | null = null
@State userTags: string[] = ['动作', '科幻', '悬疑']
@State likedItems: number[] = []
@State dislikedItems: number[] = []
@State searchQuery: string = ''
@State currentExploreCat: string = '全部'

为什么这么少?

  • 教学数据是静态的:16个物品的数据定义在 allItems 数组中,使用普通成员变量而非 @State,因为数据永不变化,不需要响应式追踪
  • 计算属性替代状态personalizedItemsfilteredItems 是计算属性(getter),内部依赖 @State 变量,当依赖变化时自动重新计算
  • 数组替代SetlikedItemsdislikedItems 使用 number[] 而非 Set<number>,因为ArkTS运行时不支持 Set 类型

状态更新流程示例

用户点击"喜欢"按钮 → toggleLike(item) 被调用 → this.likedItems 被新数组替换 → ArkUI检测到 @State 变化 → 依赖 likedItemsscoreItem() 重新执行 → personalizedItems 重新排序 → UI更新

这是一个完整的单向数据流闭环:Action → State Mutation → Re-computation → UI Re-render。

3.3 数据模型设计

interface Item {
  id: number           // 唯一标识
  title: string        // 标题
  type: string         // 类型(电影/图书/剧集)
  year: number         // 发布年份
  tags: string[]       // 标签数组(用于匹配)
  rating: number       // 评分(1.0~10.0)
  热度: number         // 热度值(中文属性名)
  desc: string         // 简介
  reason: string       // 推荐理由(人工撰写)
  image: string        // 封面emoji
}

interface ScoredMatch {
  item: Item
  match: number
}

设计考量

  • 热度 使用中文属性名——这在ArkTS中是合法的,展示了ArkTS对Unicode标识符的支持
  • tags 使用数组而非字符串,方便标签匹配时直接 includes 判断
  • reason 字段是人工撰写的推荐理由,让推荐结果更有"温度",而不是冷冰冰的算法输出
  • ScoredMatch 是内部辅助接口,用于关联推荐算法中的临时数据

为什么选择16个物品?

16个物品是经过精心选择的数字。从推荐系统的角度看,16个物品刚好可以覆盖4个页面各展示一屏内容(每屏约4~6个卡片)。从算法测试的角度,16个物品包含了足够多的标签组合和评分梯度,可以充分验证评分算法的正确性。从用户体验的角度,16部知名作品的集合既不会让用户在首次使用时感到信息过载,又提供了足够的探索空间。

数据字段的命名规范

在ArkTS中,接口字段的命名支持驼峰(camelCase)、下划线(snake_case)以及中文等Unicode字符。在项目中,我统一使用了英文驼峰命名,只有一个例外——热度 使用了中文。这个选择有两个原因:一是演示ArkTS对Unicode标识符的支持能力;二是因为"热度"这个词在中文语境中比对应的英文 popularity 更加直观。在正式项目中,建议统一使用英文命名以保持代码风格的一致性。

接口定义的位置选择

ItemScoredMatch 接口定义在文件的末尾,而不是文件的开头或组件的上方,是基于ArkTS的编译顺序规则。在ArkTS中,接口和类型的声明可以在文件中的任何位置,编译器会自动处理声明的顺序。将接口放在文件末尾,可以让读者先看到主要的组件和业务逻辑,再了解数据结构的定义,符合"从主到次"的阅读习惯。


四、UI/UX设计

4.1 视觉风格

App采用深蓝色(#1A237E)作为主色调,传递科技感与智能感,与"推荐引擎"的主题高度契合。

色彩系统

色值 用途
#1A237E 头部背景、按钮主色、底部导航选中态
#FFFFFF 卡片背景、文字主色
#F5F5F5 页面底色
#E8EAF6 标签底色、推荐理由背景
#5C6BC0 标签文字色、推荐理由文字色
#FFB300 评分星标色
#333333 标题文字
#666666 / #888888 辅助/次要文字

色彩对比度检查

  • #333#FFF 背景上:对比度约 10:1(远超 WCAG AA 的 4.5:1 标准)
  • #5C6BC0#E8EAF6 背景上:对比度约 4.8:1(符合 AA 标准)
  • #1A237E#FFF 背景上:对比度约 12:1(极佳)

配色中的心理学考量

深蓝色(#1A237E)在色彩心理学中代表专业、信任、智慧——这些恰恰是推荐引擎希望传递给用户的感受。与高尔夫教学App使用的绿色系(代表自然与成长)形成鲜明对比,说明不同业务场景需要不同的色彩策略。

在选择辅助色时,我刻意避免使用过于鲜艳的色彩,以免分散用户对内容的注意力。标签使用柔和的蓝紫色(#5C6BC0与#E8EAF6的组合),既与主色调保持统一,又不会喧宾夺主。评分星标使用金黄色(#FFB300),这是移动端产品中"评分"的通用色彩语言,用户看到金色就能自然联想到"好评"。

间距与排版的统一性

整个App统一使用 16vp 作为内容区的水平内边距,18vp 作为卡片的内边距,14~20fp 作为文字字号范围。这种数值上的统一带来了视觉上的和谐感——用户在不同页面之间切换时,不会因为间距和字号的变化而感到突兀。

在字号体系上,我遵循"对比递进"原则:标题(20fp)、正文(16fp)、辅助文字(1314fp)、次要文字(12fp)。每两级字号之间的视觉差异保持在25%30%,既保证了信息层级的清晰区分,又不会因为字号跳跃过大而破坏整体感。

4.2 卡片式设计

每个推荐物品展示为一个卡片,卡片结构如下:

┌──────────────────────────────────────────┐
│  🚀  星际穿越                    ⭐ 9.4  │  ← 封面 + 标题 + 评分
│       📂 电影 · 📅 2014 · 🔥 9800       │  ← 元数据
│       #科幻  #冒险  #剧情                 │  ← 标签行
├──────────────────────────────────────────┤
│  💡 根据您的科幻标签推荐                   │  ← 推荐理由(首页专用)
├──────────────────────────────────────────┤
│  📊 58分              [📋详情] [🤍] [👎]  │  ← 底部操作区
└──────────────────────────────────────────┘

设计细节

  • 卡片圆角 18vp,阴影 { radius: 8, color: '#0F000000', offsetY: 4 },产生轻微浮起效果
  • 推荐理由 使用浅蓝紫色背景(#E8EAF6)与卡片主体区分,视觉上引导用户关注
  • 底部操作区 包含推荐分、详情按钮、喜欢/不喜欢按钮,三个操作并排,符合移动端交互习惯
  • 封面使用 emoji:零资源文件、零加载延迟、零包体积

4.3 详情页的信息架构

详情页采用"瀑布流"布局,从上到下依次展示:

┌──────────────────────────────────┐
│  ← 返回                           │
│  🚀  星际穿越                     │  ← 大标题 + emoji
│  ⭐ 9.4  |  电影 · 2014  |  🔥 9800│  ← 元数据
├──────────────────────────────────┤
│  #科幻  #冒险  #剧情               │  ← 标签
├──────────────────────────────────┤
│  💡 推荐理由                       │  ← 推荐理由
│  根据您的科幻标签推荐               │
├──────────────────────────────────┤
│  📊 推荐评分分析                    │  ← 评分构成
│  标签匹配: ████████░░ 48/60       │
│  评分加权: ████████░░ 47/60       │
│  热度加权: █████████░ 98/100      │
│  用户反馈: ░░░░░░░░░░ 0/30       │
│  综合推荐分:58 分                 │
├──────────────────────────────────┤
│  📝 简介                          │  ← 物品描述
│  一队探险家利用他们针对虫洞...     │
├──────────────────────────────────┤
│  👥 喜欢这个的人也喜欢              │  ← 关联推荐
│  盗梦空间 ⭐ 9.3                  │
│  流浪地球 ⭐ 8.9                  │
├──────────────────────────────────┤
│  [❤️ 已收藏]  [👎 不感兴趣]        │  ← 操作按钮
│  [返回首页]                       │
└──────────────────────────────────┘

信息架构的设计原则

  1. 自上而下,从概括到细节:先展示标题和元数据让用户快速了解概况,再深入评分分析
  2. 推荐原因可视化:评分分析用进度条直观展示每个维度的贡献值
  3. 扩展引导:详情页末尾提供关联推荐,延长用户停留时间,增加交互深度
  4. 操作按钮:收藏和反馈按钮在页面底部,符合拇指操作区域

五、开发过程中的关键挑战

5.1 Set 类型不支持

问题描述

最初使用 Set<number> 存储用户点赞和不感兴趣的物品ID:

// ❌ 运行时白屏
@State likedItems: Set<number> = new Set()
@State dislikedItems: Set<number> = new Set()

编译通过,但运行时白屏——ArkTS的运行时环境不支持 Set 数据结构。

解决方案

改用 number[] 数组,手动实现 hasindexOf)、addpush)、deletesplice)等操作:

// ✅ 正确做法
@State likedItems: number[] = []
@State dislikedItems: number[] = []

// 检查是否存在:.indexOf(id) >= 0 替代 .has(id)
if (this.likedItems.indexOf(item.id) >= 0) { ... }

// 添加元素:.push(id) 替代 .add(id)
this.likedItems.push(item.id)

// 删除元素:.splice(idx, 1) 替代 .delete(id)
this.likedItems.splice(index, 1)

// 复制数组:[...arr] 替代 new Set(arr)
const newLiked = [...this.likedItems]

// 重置为空:= [] 替代 = new Set()
this.likedItems = []

教训:ArkTS是TypeScript的子集,删除了一些JavaScript运行时特性。在使用内置数据结构前,先确认其在ArkTS运行时中的兼容性。一般来说,基础类型(string、number、boolean)和数组(Array)是最安全的,而 SetMapSymbolProxy 等ES6+特性可能不受支持。

5.2 CSS渐变语法不支持

问题描述

在页面头部使用了CSS风格的渐变背景:

// ❌ 运行时白屏
.backgroundColor('linear-gradient(180deg, #1A237E, #283593)')

ArkUI的 backgroundColor 属性只接受单一色值,不支持CSS渐变语法。

解决方案

统一使用纯色背景:

// ✅ 正确做法
.backgroundColor('#1A237E')

如果想实现渐变效果,需要使用 LinearGradient 组件或自定义绘制——但最简单的方案是使用纯色并搭配不同明度的辅助色来丰富视觉层次。

5.3 RGBA颜色格式不支持

问题描述

使用了CSS风格的RGBA颜色:

// ❌ 运行时可能白屏
.fontColor('rgba(255,255,255,0.85)')
.backgroundColor('rgba(255,255,255,0.2)')
.shadow({ color: 'rgba(0,0,0,0.06)' })

ArkUI的 ResourceColor 类型不支持 rgba() 函数格式。

解决方案

使用8位十六进制颜色(ARGB格式):

// ✅ 正确做法
.fontColor('#D9FFFFFF')      // rgba(255,255,255,0.85)
.backgroundColor('#33FFFFFF') // rgba(255,255,255,0.2)
.shadow({ color: '#0F000000' }) // rgba(0,0,0,0.06)

8位Hex格式说明

透明度 4位RGBA 8位ARGB
100% rgba(255,255,255,1.0) #FFFFFFFF
85% rgba(255,255,255,0.85) #D9FFFFFF
80% rgba(255,255,255,0.8) #CCFFFFFF
70% rgba(255,255,255,0.7) #B3FFFFFF
40% rgba(255,255,255,0.4) #66FFFFFF
20% rgba(255,255,255,0.2) #33FFFFFF
6% rgba(0,0,0,0.06) #0F000000

前两位是Alpha通道(00=完全透明,FF=完全不透明),后六位是RGB色值。

5.4 Scroll 嵌套问题

DetailPage 中有一个 Scroll 容器,用于包裹详情内容。在最初的版本中,HomePage 也存在一个 Scroll。当 detail 页面通过 if/else 条件渲染被激活时,不会有嵌套Scroll问题,因为 HomePageScroll 不会被渲染。

但如果未来在 build() 方法中用一个外层 Scroll 包裹所有页面,就会导致 DetailPage 的内层 Scroll 与外层 Scroll 冲突。解决方案是:确保同一时刻只有一个 Scroll 容器处于活动状态。

5.5 编译时与运行时的差异

这是鸿蒙NEXT开发中最容易踩坑的地方——编译通过不代表运行正常

本App中遇到的所有白屏问题都是运行时错误,而不是编译错误。ArkTS编译器在编译期进行的是类型检查语法检查,但某些JavaScript运行时特性(如 Set)在编译器看来是合法的类型,在ArkTS运行时中却不存在对应的实现。

开发者应对策略

  1. 优先使用基础类型stringnumberbooleanArray、对象字面量
  2. 避免ES6+新特性SetMapSymbolProxyReflectGenerator
  3. 颜色格式用Hex:避免 rgba()hsla()hsl() 等CSS颜色函数
  4. 渐变用纯色替代linear-gradient 不支持
  5. 遇白屏先检查控制台:查看DevEco Studio的Logcat输出,定位具体错误

关于调试工具的补充

当App出现白屏时,除了检查控制台日志外,还可以采用"二分法"来快速定位问题——将 build() 方法中的子组件逐个注释掉,每注释一个就重新编译运行一次。如果注释掉某个组件后白屏消失了,说明问题就出在这个组件或其子组件中。在本次开发中,这种方法帮助我锁定了 Set 数据类型和 rgba() 颜色格式这两个关键问题。

DevEco Studio的预览器(Previewer)也是一个强大的调试工具。与真机运行不同,预览器可以在IDE中实时显示UI的变化,并且在渲染失败时通常会给出更明确的错误提示。建议在开发阶段大量使用预览器,只在最终验证时才使用真机运行。预览器还支持热重载——修改代码后自动更新预览,无需重新编译。这种"改完即看"的开发体验,能大幅缩短调试周期。

还有一种有效的调试手段是"最小化复现法"——创建一个全新的、只包含一个Text组件的最小化页面,确认它能正常显示后,再逐步添加功能。如果最小化页面都不能显示,说明是项目配置或开发环境的问题;如果能显示,说明是新增代码的问题。我在解决白屏问题时,就是用这种方法将1074行的完整代码缩减到71行的最小化测试页面,确认基础渲染正常后再逐块还原代码,最终定位到所有问题。


六、编译与构建

6.1 项目配置

// build-profile.json5
{
  "app": {
    "products": [
      {
        "name": "default",
        "targetSdkVersion": "6.1.0(23)",
        "compatibleSdkVersion": "6.1.0(23)",
        "runtimeOS": "HarmonyOS"
      }
    ]
  }
}

配置说明:

  • targetSdkVersion: "6.1.0(23)":编译目标API 24
  • runtimeOS: "HarmonyOS":纯血鸿蒙运行环境
  • apiType: "stageMode":使用Stage应用模型(FA模型已废弃)

6.2 编译命令

# 全量编译
hvigorw --mode module -p module=entry -p product=default assembleHap

# 清理缓存
hvigorw clean

# 仅编译ArkTS(快速检查语法)
# 编译流水线中 CompileArkTS 是耗时最长的步骤

编译耗时

  • 干净构建:约 3~8 秒
  • 增量构建:约 1~3 秒
  • 仅CompileArkTS:约 1 秒

6.3 常见编译错误及修复

错误1:Flex组件不支持wrap属性

ERROR: Property 'flexWrap' does not exist on type 'RowAttribute'.

在ArkUI中,Row 组件不支持 flexWrap 属性。需要用 Flex 组件替代 Row

// ❌ Row 不支持 flexWrap
Row() { ... }
  .flexWrap(FlexWrap.Wrap)

// ✅ 使用 Flex 组件
Flex({ wrap: FlexWrap.Wrap }) { ... }

错误2:@Builder 中的局部变量

ERROR: Only UI component syntax can be written here.

@Builder 函数中,不能写 letforif(条件表达式除外)等语句。只能写UI组件语法:

// ❌ 不允许的写法
@Builder
MyBuilder() {
  let x = 123  // 错误!
  for (...) {}  // 错误!
}

// ✅ 允许的写法
@Builder
MyBuilder() {
  if (condition) {  // 条件UI渲染
    Text('hello')
  }
  ForEach(arr, () => { ... })  // 列表渲染
}

错误3:对象字面量作为类型声明

ERROR: Object literals cannot be used as type declarations.

ArkTS不支持使用对象字面量作为类型声明,需要先定义interface:

// ❌ 不允许
let scored: { item: Item; match: number }[] = []

// ✅ 需要定义 interface
interface ScoredMatch {
  item: Item
  match: number
}
let scored: ScoredMatch[] = []

错误4:@Builder 中的 this 引用

ERROR: Using 'this' inside stand-alone functions is not supported.

@Builder 函数内部,不能使用 let 声明变量再调用 this.xxx()。需要将逻辑移到类的普通方法中:

// ❌ 不允许在 @Builder 中用 let
@Builder
MyBuilder() {
  let data = this.getData()  // 错误
  if (data.length > 0) { ... }
}

// ✅ 直接在条件或 ForEach 中调用方法
@Builder
MyBuilder() {
  if (this.getData().length > 0) { ... }
  ForEach(this.getData(), ...)
}

七、性能优化

7.1 条件渲染 vs 显隐控制

// ✅ 推荐:条件渲染(不满足条件时不创建节点)
if (this.currentPage === 'home') {
  this.HomePage()
}

// ❌ 避免:显隐控制(节点始终存在,只是隐藏)
// this.HomePage().display(this.currentPage === 'home' ? 'flex' : 'none')

条件渲染的优势:

  • 不显示时完全不占用内存和GPU资源
  • 切换页面时旧组件销毁,不会有残留事件监听
  • 结构清晰,便于调试

条件渲染的注意事项

使用条件渲染时,需要注意组件状态的丢失问题。当一个组件通过 if 条件被销毁再重建时,其内部的所有状态都会重置。在本App中,当用户从详情页返回首页时,HomePage 被重新创建,推荐列表会重新排序——这恰好是我们期望的行为,因为用户的点赞/不感兴趣操作已经改变了 likedItemsdislikedItems,列表需要刷新。

但如果某个页面有复杂的临时状态(如下拉位置、展开/折叠状态),就应该考虑改用显隐控制或使用 @State 提升状态到父组件。这是一个需要根据具体场景权衡的设计决策。

7.2 计算属性的缓存

ArkUI的计算属性(getter)在每次渲染时都会重新计算。如果计算逻辑复杂,可以考虑引入缓存机制:

// 当前实现:每次渲染都重新计算
get personalizedItems(): Item[] {
  return this.allItems
    .filter(item => this.dislikedItems.indexOf(item.id) < 0)
    .sort((a, b) => this.scoreItem(b) - this.scoreItem(a))
}

对于本App来说,16个物品的排序计算在毫秒级别完成,不需要缓存。但如果物品数量增长到数千甚至数万,就需要考虑:

  • 使用 @Watch 装饰器监听状态变化,变化时才重新计算
  • 使用 LazyForEach 实现虚拟列表,只渲染可见区域
  • 将排序逻辑放到 Web Worker 中执行

7.3 列表渲染的性能

ForEach 在渲染列表时,会为每个元素创建对应的组件节点。对于长列表(超过100项),建议:

  1. 使用 LazyForEach 替代 ForEach,实现按需渲染
  2. 始终提供唯一的 keyGenerator,帮助框架高效Diff
  3. 控制 slice 数量,不要一次渲染全部数据

本App中最多渲染16个物品,完全在 ForEach 的高效区间内。

7.4 最小化状态集

这是本文反复强调的一个原则:状态越少,性能越好

ArkUI中每个 @State 变量都会增加变更检测的开销。在App中,我只用了4个核心状态变量(currentPageselectedItemuserTagslikedItems/dislikedItems)和2个UI状态(searchQuerycurrentExploreCat)。

静态数据(16个物品的完整数据)放在普通成员变量 allItems 中,不参与响应式追踪,零性能开销。


八、从高尔夫教学到推荐引擎:ArkTS的复用经验

在同一个项目中,我先后开发了两个完全不同的App——“高尔夫挥杆教学"和"个性化推荐引擎”。这两个App虽然在业务逻辑上天差地别,但在ArkTS代码层面,有大量可以复用的模式和技巧:

8.1 通用架构模式

两个App都采用了同样的基本架构:

@Entry @Component struct AppName {
  @State currentPage: 'page1' | 'page2' | 'page3' = 'page1'
  
  build() {
    if (this.currentPage === 'page1') { this.Page1() }
    else if (...) { this.Page2() }
    
    if (this.currentPage !== 'detail') { this.BottomBar() }
  }
  
  @Builder Page1() { ... }
  @Builder Page2() { ... }
}

这种"单页面多视图"架构适用于大多数中小型鸿蒙应用,推荐作为项目模板使用。

8.2 可复用的UI组件模式

两个App都广泛使用了卡片组件:

  • 数据模型:定义interface来描述卡片数据
  • @Builder:用函数式组件封装卡片的UI结构
  • props传参:通过参数传递数据和回调函数
  • 条件样式:根据状态变量切换颜色、文字
// 通用卡片模式
@Builder
Card(icon: string, title: string, subtitle: string, onClick: () => void) {
  Row({ space: 12 }) {
    Text(icon).fontSize(36)
    Column({ space: 4 }) {
      Text(title).fontSize(18).fontWeight(FontWeight.Bold)
      Text(subtitle).fontSize(14).fontColor('#888')
    }
  }
  .width('100%')
  .padding(14)
  .backgroundColor('#FFFFFF')
  .borderRadius(16)
  .onClick(onClick)
}

8.3 共同的踩坑记录

两个App的开发过程中,遇到了几乎完全相同的坑:

  1. linear-gradient 不支持 → 改用纯色
  2. rgba() 不支持 → 改用8位hex
  3. 嵌套 Scroll → 确保不嵌套
  4. @Builder 中不能写 let → 把逻辑移到类方法

这些坑是ArkTS开发者的"必经之路",记录下来可以帮助后续的开发者少走弯路。


九、数据与测试

9.1 测试数据设计

App内置了16个物品,覆盖电影、图书、剧集三大类型,每个物品包含7个维度的数据。测试数据的设计遵循以下原则:

  1. 多样性:覆盖不同年代(18872022)、不同类型(科幻到爱情)、不同评分(8.59.7)
  2. 标签重叠:故意让某些物品共享标签(如多部"科幻"作品),使推荐算法能产生有意义的结果
  3. 区分度:评分和热度值有梯度分布,确保排序结果有变化
  4. 真实性:所有作品都是真实存在的知名作品,便于读者理解和使用

9.2 测试用例

测试1:标签匹配

初始用户标签为 ['动作', '科幻', '悬疑'],评分前3的结果应为:

  1. 《星际穿越》(匹配"科幻",评分9.4,热度9800)
  2. 《盗梦空间》(匹配"科幻"+“悬疑”,评分9.3,热度9500)
  3. 《三体》(匹配"科幻",评分9.5,热度12000)

测试2:用户反馈

对《星际穿越》点赞(+30分),对《三体》不感兴趣(-50分),排序应变为:

  • 《星际穿越》分数上升30分,排名可能升至第一
  • 《三体》分数下降50分,排名显著下降

测试3:关联推荐

查看《星际穿越》详情页时,"喜欢这个的人也喜欢"应优先推荐:

  • 《盗梦空间》(共享"科幻"标签)
  • 《流浪地球》(共享"科幻""冒险"标签)
  • 《黑客帝国》(共享"科幻"标签)

测试4:搜索过滤

在探索页搜索"科幻",应返回所有包含"科幻"标签的物品(约5~6个)。
搜索"三体",应精确匹配到《三体》图书。

9.3 用户引导设计

App设计了几个隐性的用户引导机制:

  1. 默认标签:用户首次使用时已有3个预设标签(动作、科幻、悬疑),避免空状态
  2. 推荐理由:每个物品都配有推荐理由,让用户理解"为什么推荐这个"
  3. 评分分析:详情页展示评分构成,让推荐结果透明化
  4. 交互反馈:喜欢/不喜欢按钮位置明显,鼓励用户反馈
  5. 偏好设置:独立的偏好页面,可以批量管理和查看当前设置

十、总结与展望

10.1 项目技术总结

通过个性化推荐引擎App的开发,我深入实践了以下技术要点:

  1. ArkTS状态管理@State + 计算属性 + 条件渲染 = 声明式UI范式
  2. 推荐算法:线性加权评分模型,在客户端实现了可解释的个性化推荐
  3. ArkUI组件:Stack/Column/Row/Scroll/Grid/Flex/Text/Button/TextInput/ForEach
  4. 编译构建:hvigorw命令行工具的使用和增量编译机制
  5. 运行时兼容性Set 不支持、rgba() 不支持、linear-gradient 不支持等坑

10.2 与高尔夫教学App的对比

维度 高尔夫挥杆教学 个性化推荐引擎
数据量 6个教学环节 + 6个练习 16个推荐物品
状态变量 4个 7个
页面数 3(首页/练习/详情) 4(首页/探索/详情/偏好)
算法复杂度 纯展示,无计算 评分排序 + 关联推荐
交互复杂度 点击跳转 点赞/不喜欢/搜索/筛选/标签选择
代码行数 910行 最初1074行
编译错误数 2个 130+个(含级联错误)

推荐引擎App的复杂度明显更高,编译错误也更多,但最终通过系统的排查和修复,构建成功。

10.3 未来功能规划

当前版本是一个功能完整的MVP,未来可以扩展的方向:

  1. 数据持久化:使用 AppStorage 或轻量级数据库保存用户偏好,关闭App后不丢失
  2. 更多推荐算法:添加协同过滤(Collaborative Filtering)、矩阵分解(Matrix Factorization)等
  3. 用户画像可视化:用图表展示用户的兴趣分布和消费偏好
  4. 个性化推送:结合后台服务实现每日推荐推送
  5. A/B测试框架:在客户端实现多算法对比测试
  6. 多端适配:一次开发同时适配手机、平板和折叠屏

10.4 给鸿蒙开发者的建议

  1. 从小型项目开始:不要第一次就尝试构建大型应用。先做一个页面少于5个、状态少于10个的小应用,跑通全流程
  2. 优先使用基础类型:避开了 SetMap 等ES6+特性,能省去大量调试时间
  3. 颜色格式用Hex:统一使用 #RRGGBB#AARRGGBB 格式
  4. 善用 @Builder:它是ArkTS最强大的代码复用机制,比 @Component 更轻量
  5. 利用命令行构建hvigorw 比IDE中的构建按钮更可控,适合CI/CD集成
  6. 查阅官方文档和变更日志:API 24相比早期版本有大量API变更,官方文档是最可靠的参考

关于学习路径的建议

对于从零开始学习鸿蒙NEXT开发的开发者,我推荐以下学习路径:

第一步,熟悉ArkTS的基础语法。如果你是前端开发者(React/Vue),ArkTS的声明式UI范式会让你感到亲切;如果你是Android/iOS原生开发者,可能需要花一些时间适应"UI即代码"的思维模式。无论从哪种背景出发,花一天时间阅读ArkTS官方语言规范都是值得的。

第二步,掌握ArkUI的核心组件。从 ColumnRowStack 这三个布局组件开始,然后学习 TextButtonImageTextInput 等基础组件,最后再深入了解 ListGridScrollSwiper 等复杂组件。不要试图一次学完所有组件——先掌握20%最常用的组件,就能覆盖80%的开发场景。

第三步,理解状态管理机制。@State@Prop@Link@Provide@Consume 这五个装饰器构成了ArkTS状态管理的核心。理解它们的区别和适用场景,是编写高质量ArkTS应用的关键。

第四步,实战练习。模仿本文中的两个App(高尔夫教学或推荐引擎),从零开始动手实现一个自己的版本。遇到问题时,参考官方文档和社区资源,独立解决问题才能获得最深刻的学习体验。

关于代码组织的建议

对于像推荐引擎这种中等复杂度的App,建议将代码按功能模块划分为多个文件,而不是全部写在一个 Index.ets 中。推荐的结构是将数据模型、测试数据、推荐算法和UI组件分别放在 model/data/service/component/ 目录下。这种按职责分层的目录结构,在项目规模增长时能有效保持代码的可维护性。不过对于本App来说,全部放在一个文件中也有其价值——代码集中,便于读者快速了解全貌,不需要在多个文件之间来回跳转。开发时应根据实际需要在这两种组织方式之间做出选择。

10.5 写在最后

个性化推荐引擎App的开发过程,是一次从"能编译通过"到"能正常运行"的艰难跨越。在这个过程中,我深刻体会到了ArkTS作为一种静态类型语言在编译时的严谨性,也感受到了它在运行时与标准JavaScript的差异。

在API 24时代,鸿蒙NEXT的ArkTS开发体验已经相当成熟,但仍有一些"成长的烦恼"。随着生态的不断完善,这些问题会逐步得到解决。作为先行者,记录和分享这些踩坑经验,本身就是对鸿蒙生态的一种贡献。

无论是高尔夫挥杆教学还是个性化推荐引擎,这些小型App的价值不在于功能有多强大,而在于它们完整地展示了从需求分析、架构设计、编码实现到编译部署的全流程。希望这篇博客能为正在学习鸿蒙NEXT开发的你,提供一些实用的参考和启发。

你可以在项目仓库中找到本文配套的完整源代码和构建说明。欢迎Fork和Star,也欢迎提交PR来改进推荐算法或增加新的功能特性。

Happy Coding, Happy Recommending! 🧠


致谢

感谢鸿蒙开发者社区提供的丰富文档和示例代码,为本项目的开发提供了重要参考。同时也感谢华为DevEco Studio团队持续改进开发工具,预览器和调试器的完善让鸿蒙NEXT的开发效率不断提升。如果你在阅读本文后有任何疑问或建议,欢迎在项目仓库中提Issue或PR,一起推动鸿蒙生态的繁荣发展。

附录A:核心代码索引

核心代码位于 entry/src/main/ets/pages/Index.ets,包含:

  • RecommendEngine 主组件
  • ItemScoredMatch 接口定义
  • 16个推荐物品的完整数据
  • 评分算法、关联推荐算法、用户交互逻辑
  • 4个页面(首页/探索/详情/偏好)+ 顶部导航 + 底部导航

附录B:推荐算法公式

Score(item) = Σ(10 for each matching tag) + rating × 5 + 热度 ÷ 100
             + (30 if liked) + (-50 if disliked)

附录C:参考资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐