鸿蒙 Next 实战:从零开发「笑话大全」App — 本地持久化 + 下拉刷新 + 上拉加载更多

目标 API 24+(HarmonyOS 7.0+) | 开发工具:DevEco Studio 6.1 | 语言:ArkTS


一、前言

在移动应用开发中,内容型应用(段子、资讯、短视频 Feed)是最常见的形态之一。这类应用的核心操作无非三件事:首次加载 → 下拉刷新 → 上拉加载更多。听起来简单,但要做得优雅——骨架屏占位、加载状态管理、分页边界处理、离线缓存——每个环节都有不少细节。

本文将以一个完整的 「笑话大全」App 为例,带你走一遍 HarmonyOS Next 上这整套流程的工程化实现。全部代码使用 ArkTS 编写,目标 API 24+(HarmonyOS 7.0+),兼顾可读性与性能。


二、项目概览

2.1 功能清单

功能 实现方式
笑话列表展示 List + ForEach,渲染 30 条带分类标签的卡片
下拉刷新 Refresh 组件 + pullToRefresh 属性
上拉加载更多 onScrollIndex 检测 + 分页追加
本地持久化 preferences(@kit.ArkData),JSON 序列化
种子数据 内置 30 条中文段子,按 5 种分类轮转
骨架屏 首次加载时显示占位卡片
加载指示器 List 底部的 LoadingProgress + 文字提示
已到底提示 分隔线 + “已经到底了 😄”

2.2 目录结构

entry/src/main/ets/
├── models/
│   └── Joke.ets              # Joke 接口定义
├── repository/
│   └── JokeRepository.ets    # 数据仓库:种子数据 + preferences 读写 + 分页
└── pages/
    └── Index.ets              # 主页面,含 6 个自定义组件

三、架构设计思路

3.1 分层

我们把代码分成三层:

┌─────────────────────┐
│     UI 层 (Pages)    │  ← 组件状态、布局、滚动事件
├─────────────────────┤
│   Repository 层      │  ← 数据读写、分页逻辑、序列化
├─────────────────────┤
│   Model 层           │  ← 纯数据类型定义
└─────────────────────┘
  • Model 层:只定义 Joke 接口,没有任何行为。
  • Repository 层:封装所有数据操作的细节。UI 层不直接碰 preferences,也不关心 JSON 序列化。
  • UI 层:只关心"给我数据"和"告诉我有没有下一页",具体的分页计算由 Repository 完成。

这种分层的好处是:如果你想日后把本地存储换成远程 API,只需要改 Repository 内部实现,UI 层几乎不用动。

3.2 数据流

用户下拉 ──→ Refresh.onRefreshing ──→ repository.init() ──→ getFirstPage()
                                                              │
                                                              ▼
用户上滑 ──→ List.onScrollIndex ──→ repository.getNextPage(n) ──→ displayedJokes 更新
                                                              │
                                                              ▼
         preferences.put(JSON.stringify(allJokes))  ← 每次写入持久化

四、Model 层:Joke 数据模型

models/Joke.ets

export interface Joke {
  id: string;
  content: string;
  category: string;
  createdAt: string;
}

ArkTS 的 interface 编译时会被擦除,不影响包体积。这里用 string 类型的 createdAt 而非 Date 对象,是因为 JSON.stringify/parse 天然支持字符串,无需自定义 reviver。


五、Repository 层:本地持久化 + 分页

5.1 种子数据

内置 30 条中文段子,覆盖 5 个分类:

const categories: string[] = [
  '冷笑话', '程序员笑话', '生活段子', '职场趣事', '夫妻日常'
];

每条笑话通过 index % 5 轮转分配分类,保证列表天然有视觉节奏感。

5.2 preferences 读写

HarmonyOS 的 @kit.ArkData 提供了 preferences 轻量级 KV 存储。使用姿势:

import { preferences } from '@kit.ArkData';

// 打开/创建存储实例
this.preferences_ = await preferences.getPreferences(context, 'joke_app_db');

// 写入
await this.preferences_.put('my_key', JSON.stringify(data));
await this.preferences_.flush();   // 立即落盘

// 读取
const value = await this.preferences_.get('my_key', '');
const parsed = JSON.parse(value as string);

⚠️ 关键细节get() 返回的是 Promise<ValueType>,必须 await。API 23+ 的类型声明中 get 的第二个参数是默认值,类型为 ValueType,需要 as string 窄化。

5.3 分页逻辑

Repository 提供两个核心方法:

/** 第一页(前 PAGE_SIZE 条) */
getFirstPage(): Joke[] {
  return this.allJokes_.slice(0, this.pageSize_);
}

/** 从完整列表中取到 currentCount + PAGE_SIZE 条 */
getNextPage(currentCount: number): Joke[] {
  const end = Math.min(currentCount + this.pageSize_, this.allJokes_.length);
  return this.allJokes_.slice(0, end);
}

/** 是否还有更多 */
hasMore(currentCount: number): boolean {
  return currentCount < this.allJokes_.length;
}

这里的设计要点是:slice(0, end) 而非维护一个 currentPage 游标。这样做的好处是:

  1. 如果用户在加载过程中新增了一条笑话(插入到数组头部),slice(0, end) 天然会把新数据包含进来,不会出现"已加载列表"与"底层数据"不一致。
  2. 逻辑简单,零状态维护。

5.4 ArkTS 对象字面量约束

ArkTS 对类型安全要求严格,禁止"匿名对象字面量"。你必须显式声明类型:

// ❌ ArkTS 编译错误:Object literal must correspond to some explicitly declared class or interface
return {
  id: 'xxx',
  content: '...',
  category: '...',
  createdAt: '...',
};

// ✅ 正确写法:先声明类型
const joke: Joke = {
  id: 'xxx',
  content: '...',
  category: '...',
  createdAt: '...',
};
return joke;

这是 ArkTS 与普通 TypeScript 的重要区别之一,初接触时容易踩坑。


六、UI 层:列表 + 刷新 + 加载更多

6.1 整体结构

Stack
 └─ Column
     ├─ TitleBar (橙色标题栏)
     └─ Refresh
         └─ List
             ├─ JokeCard × N
             ├─ LoadingFooter / EndFooter

6.2 Refresh 组件

HarmonyOS 的 Refresh 组件用法简洁:

Refresh({ refreshing: $$this.isRefreshing }) {
  // 列表内容
}
.onRefreshing(() => {
  this.onRefreshAction();
})
.pullToRefresh(true)
  • refreshing 绑定双向状态 $$this.isRefreshing
  • onRefreshing 回调中执行刷新逻辑
  • pullToRefresh(true) 启用下拉手势

⚠️ 注意:API 23 中回调名为 onRefreshing不是 onRefresh。两者区别在于 onRefreshing 是 Refresh 组件专属的事件回调,而 onRefresh 不存在于 RefreshAttribute 上。

6.3 上拉加载更多:onScrollIndex

List 组件提供了 onScrollIndex 回调,会在滚动时向应用报告当前可见的第一项和最后一项的索引:

List() { ... }
.onScrollIndex((firstIndex: number, lastIndex: number) => {
  const threshold = 3;
  if (lastIndex >= this.displayedJokes.length - threshold
    && this.hasMoreData && !this.isLoading) {
    this.loadMore();
  }
})

当最后可见索引 lastIndex 距离列表末尾不足 3 项时,触发加载更多。threshold = 3 是为了预加载——在用户划到最底部之前就开始加载下一页,消除等待感。

6.4 卡片组件(JokeCard)

每个笑话卡片的结构:

┌─────────────────────────────────┐
│ ❄️ 冷笑话              3天前    │
│                                 │
│ 为什么程序员总是把万圣节和...     │
│ 因为 Oct 31 和 Dec 25 在...     │
└─────────────────────────────────┘

卡片采用全圆角(12vp) + 浅阴影设计,分类标签使用品牌色 #FF6B35 的浅色背景,时间文本使用灰色弱化视觉权重。

6.5 组件属性可见性

ArkTS 中 @Component struct 的属性,如果通过构造函数传值,不能标记为 private

// ❌ 编译错误:Property 'showBack' is private and can not be initialized through constructor
@Component
struct TitleBar {
  private showBack: boolean = false;  // 不可用
}

// ✅ 正确
@Component
struct TitleBar {
  showBack: boolean = false;  // public,允许构造器传值
}

这是因为 ArkTS 的构造函数语法 TitleBar({ showBack: true }) 本质上是在初始化 struct 的字段,而 private 阻止了外部访问。


七、关于 API 24

7.1 为什么选择 API 24+

HarmonyOS API 24(对应 HarmonyOS 7.0+)相比 API 23 引入了更完善的 ArkUI 组件能力和更严格的类型检查。本文的代码在 API 23 上也可编译运行,但为了面向未来,项目配置已预留 API 24 兼容:

// build-profile.json5
{
  "app": {
    "products": [{
      "targetSdkVersion": "7.0.0(24)",
      "compatibleSdkVersion": "7.0.0(24)",
    }]
  }
}

7.2 迁移检查清单

从 API 23 → API 24 迁移时,需要关注:

  1. SDK 安装:通过 DevEco Studio → Settings → SDK Manager 下载 HarmonyOS 7.0+ SDK
  2. 模块依赖:检查 oh-package.json5 中依赖的 kit 版本是否支持 API 24
  3. 废弃 API:本文代码中的 getContext(this) 在 API 23 已标记废弃,API 24 可改用 this.getUIContext()
  4. 代码无需改动:本文所有组件(Refresh、List、LoadingProgress、preferences)均在 API 8+ 就存在,API 24 无破坏性变更

八、完整代码解析

8.1 Joke.ets — 13 行

纯粹的接口定义,零依赖。

8.2 JokeRepository.ets — 212 行

将上面讲到的所有逻辑整合在一起:

init()              → 获取 preferences 实例 → 加载或创建种子数据
loadFromStorage()   → await get() → JSON.parse
saveToStorage()     → put() → flush()
getFirstPage()      → slice(0, PAGE_SIZE)
getNextPage(n)      → slice(0, min(n+PAGE_SIZE, total))
hasMore(n)          → n < total
createSeedJokes()   → 30 条 → Joke[](显式类型)
addJoke()           → unshift + save
resetToSeed()       → 重新生成 + save

8.3 Index.ets — 416 行

包含 6 个 @Component

组件 行数 职责
Index 150+ 根组件,管理状态 + 事件
TitleBar 36 橙色标题栏
JokeCard 63 单条笑话卡片
LoadingFooter 24 “上拉加载更多”/“加载中…”
EndFooter 26 “已经到底了 😄”
LoadingSkeleton 33 首次加载占位
LoadingDialog 24 全屏加载弹窗

8.4 状态管理

Index 组件使用 5 个 @State

@State displayedJokes: Joke[] = [];   // 当前渲染的笑话列表
@State isLoading: boolean = false;     // 是否正在加载
@State hasMoreData: boolean = true;    // 是否还有下一页
@State isRefreshing: boolean = false;  // 是否正在下拉刷新
@State isInitialized: boolean = false; // 首次加载是否完成

每个状态变化都会触发 ArkUI 的响应式重渲染,无需手动操作 DOM。


九、工程化注意事项

9.1 避免循环依赖

ArkTS 编译时要求 import 图无环。本文的分层方案天然避免了这一点:

Joke.ets ← JokeRepository.ets ← Index.ets

数据从 Repository 单向流向 UI。

9.2 ForEach 的 key 生成

ForEach 需要一个稳定且唯一的 key 生成器,用于列表 diff 优化:

ForEach(this.displayedJokes, (item: Joke) => {
  ListItem() { JokeCard({ joke: item }) }
}, (item: Joke) => item.id)  // ← 第三个参数:key 生成器

如果不用 key 生成器,ArkUI 在列表追加/删除时会全量重建,影响性能。

9.3 延迟模拟

为了让用户感知到加载过程(而非瞬间完成),loadMore() 中加入了 300ms 延迟:

async loadMore(): Promise<void> {
  this.isLoading = true;
  await this.delay(300);
  // ... 加载数据
}

在实际应用中,如果使用远程 API,这个延迟自然会被网络请求取代,不需要人工加 delay。


十、总结

在本文中,我们完成了一个完整的 HarmonyOS Next 内容型 App:

  • 本地持久化:preferences KV 存储,JSON 序列化,启动秒加载
  • 下拉刷新:Refresh 组件,绑定 $$ 双向状态
  • 上拉加载更多:onScrollIndex 预加载触发,slice 分页
  • API 24+ 就绪:代码向前兼容,配置一键切换
  • 优雅体验:骨架屏、分类标签、加载指示器、已到底提示

本文的全部代码可以在 [GitHub / Gitee] 获取。如果你正在从 Android/iOS 转向 HarmonyOS,或者从 TypeScript 转向 ArkTS,希望这篇文章对你有所帮助。

如果你有任何问题或改进建议,欢迎在评论区留言讨论!


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

Logo

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

更多推荐