云计算百科
云计算领域专业知识百科平台

【笔下生辉|08】HarmonyOS ArkTS 收藏页实战:处理收藏切换、列表刷新和空状态

学习类 HarmonyOS 应用里,“收藏页”通常不是一个单纯的列表页。用户在答题页收藏一道题、写一条笔记、答错一次题,回到学习夹时希望马上看到变化;如果点开收藏题目继续练习,又希望从这道题附近开始,而不是跳到无关题目。这个页面如果自己维护一份独立副本,很容易出现列表数量不刷新、空状态不消失、题卡点击错位的问题。

笔下生辉的收藏页由 FavoritePage.ets 承担。它没有重新拉取远程数据,也没有创建单独的页面缓存,而是通过 @StorageLink 直接订阅 favoriteRecords、noteRecords、wrongRecords 和 favoriteTabIndex。收藏切换、笔记保存、错题收录主要发生在 PracticePage.ets 和 UserDataManager.ets,收藏页只根据共享状态渲染三类列表、三个空状态、笔记编辑弹层和继续练习入口。

本文基于本地工程 D:\\huawei\\one17-11 的真实源码编写,包名标记为 com.jiaweikang.one17。这里需要明确边界:当前源码没有云同步、账号收藏夹、服务端收藏接口或跨设备协同;它是基于 Preferences + AppStorage 的本地学习记录管理。文章只讨论源码可复核的收藏切换、列表刷新、空状态、笔记编辑、错题清空和题卡跳转。

收藏页封面

本文重点解决五个工程问题:

  • 如何让收藏、笔记、错题三类记录共用 AppStorage 状态。
  • 如何通过 favoriteTabIndex 让外部页面直接切到指定分栏。
  • 如何在三类列表为空时展示明确空状态。
  • 如何编辑笔记并让列表内容即时刷新。
  • 如何从收藏页题卡回到练习页,并尽量定位到原题。
  • 收藏页刷新闭环

    收藏页职责结构

    一、收藏页不是数据源,而是共享状态的视图

    FavoritePage.ets 开头就能看出它的数据来源。三个记录列表和当前分栏都来自 @StorageLink,这意味着它们和其他页面共享同一份 AppStorage 状态。

    @StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = []
    @StorageLink('noteRecords') noteRecords: NoteRecord[] = []
    @StorageLink('wrongRecords') wrongRecords: WrongRecord[] = []
    @StorageLink('favoriteTabIndex') tab: number = 0
    @State showEditNote: boolean = false
    @State editingQuestionId: string = ''
    @State editingBankId: string = ''
    @State editingNoteText: string = ''

    这里可以分清两类状态:

    状态类型生命周期
    favRecords/noteRecords/wrongRecords @StorageLink 跨页面共享和持久化恢复
    tab @StorageLink 外部页面可切换收藏页分栏
    showEditNote/editing* @State 当前页面临时编辑态

    这种拆分能避免收藏页在 aboutToAppear 里反复手动刷新。只要答题页或设置页更新了 AppStorage 里的数组,收藏页重新渲染时就能使用最新值。

    二、数据初始化发生在 EntryAbility,不由页面各自处理

    EntryAbility.ets 初始化用户数据,并创建 favoriteTabIndex。

    UserDataManager.init(this.context);
    AppStorage.setOrCreate<number>('favoriteTabIndex', 0);

    UserDataManager.init 读取 Preferences 并写入 AppStorage:

    const favStr = UserDataManager.prefs.getSync(UserDataManager.K_FAV, '[]') as string
    const noteStr = UserDataManager.prefs.getSync(UserDataManager.K_NOTES, '[]') as string
    const wrongStr = UserDataManager.prefs.getSync(UserDataManager.K_WRONG, '[]') as string

    AppStorage.setOrCreate<FavoriteRecord[]>('favoriteRecords', JSON.parse(favStr) as FavoriteRecord[])
    AppStorage.setOrCreate<NoteRecord[]>('noteRecords', JSON.parse(noteStr) as NoteRecord[])
    AppStorage.setOrCreate<WrongRecord[]>('wrongRecords', JSON.parse(wrongStr) as WrongRecord[])

    这条链路很关键。收藏页不应该直接读取 Preferences,否则答题页、设置页、统计页各自读写存储,很容易出现状态不同步。当前源码把持久化恢复集中在 UserDataManager,页面通过 AppStorage 消费结果。

    三、收藏切换在答题页发生,收藏页自动接收结果

    收藏按钮位于 PracticePage.ets。当前题目存在时,点击收藏会调用 UserDataManager.toggleFavorite,并把返回的新数组赋给 favRecords。

    this.favRecords = UserDataManager.toggleFavorite(this.favRecords, q.id, q.bankId)

    toggleFavorite 的逻辑是:如果已经收藏,就过滤掉这条记录;如果未收藏,就把新记录插到数组前面,并持久化。

    static toggleFavorite(records: FavoriteRecord[], questionId: string, bankId: string): FavoriteRecord[] {
    const exists = records.some((record: FavoriteRecord) => record.questionId === questionId)
    let result: FavoriteRecord[]
    if (exists) {
    result = records.filter((record: FavoriteRecord) => record.questionId !== questionId)
    } else {
    result = [{ questionId, bankId, createdAt: nowStr() }, …records]
    }
    UserDataManager.persist(UserDataManager.K_FAV, result)
    return result
    }

    这不是原地修改数组,而是返回新数组。对 ArkUI 状态刷新来说,这一点很重要:赋值给 @StorageLink 后,依赖该数组的页面才更容易触发重新渲染。

    四、Tabs 用同一套长度显示实时计数

    收藏页顶部是三个分栏:收藏、笔记、错题。数量通过 countFor(index) 从当前数组长度计算。

    private countFor(index: number): number {
    if (index === 0) return this.favRecords.length
    if (index === 1) return this.noteRecords.length
    return this.wrongRecords.length
    }

    渲染时每个 tab 都展示标签和数量:

    ForEach(['收藏', '笔记', '错题'], (label: string, index: number) => {
    Row({ space: 5 }) {
    Text(label)
    .fontSize(Sizes.CAPTION_FONT)
    .fontWeight(this.tab === index ? FontWeight.Bold : FontWeight.Normal)
    .fontColor(this.tab === index ? Color.White : Colors.TEXT_SECONDARY)
    Text(`${this.countFor(index)}`)
    .fontSize(Sizes.SMALL_FONT)
    .fontColor(this.tab === index ? Color.White : Colors.TEXT_HINT)
    }
    .layoutWeight(1)
    .height(38)
    .justifyContent(FlexAlign.Center)
    .borderRadius(19)
    .backgroundColor(this.tab === index ? Colors.PRIMARY : Colors.SURFACE)
    .onClick(() => { this.tab = index })
    }, (label: string, index: number) => `ftab_${index}`)

    这里没有单独维护 favCount、noteCount、wrongCount。计数从数组长度直接派生,可以避免“列表已经删空但顶部数字仍是 1”的问题。

    五、外部页面可以通过 favoriteTabIndex 定位分栏

    favoriteTabIndex 是共享状态,因此其他页面可以先改分栏,再切到收藏页。例如首页错题复习入口会这样做:

    this.favoriteTabIndex = 2
    this.currentTabIndex = 3

    设置页也可以打开对应学习数据页:

    private openFavoriteTab(tabIndex: number): void {
    this.currentTabIndex = 3
    this.favoriteTabIndex = tabIndex
    }

    收藏页只需要把 tab 绑定到 @StorageLink('favoriteTabIndex'),就能承接外部导航意图。这比给收藏页维护多个路由参数更轻,适合这种底部 Tab 内部页面。

    六、主体渲染按分栏拆成三个列表

    收藏页主体根据 tab 选择渲染收藏、笔记或错题。

    if (this.tab === 0) {
    this.FavList()
    } else if (this.tab === 1) {
    this.NoteList()
    } else {
    this.WrongList()
    }

    这个分支的价值是让每类记录拥有自己的空状态和动作,而不是把三类记录强行塞到一个泛型列表里。

    分栏数据源特有动作
    收藏 favRecords 点击题卡继续练习
    笔记 noteRecords 展示笔记内容并支持编辑
    错题 wrongRecords 错题练习、清空错题

    对题库应用来说,收藏、笔记、错题不是同一种业务。源码保持同一个 QuestionCard 展示框架,同时让列表入口有差异,是比较稳的折中。

    七、收藏列表为空时显示明确动作提示

    收藏列表先判断数组长度。为空时进入 EmptyHint,非空时用 List 渲染。

    @Builder
    FavList() {
    if (this.favRecords.length === 0) {
    this.EmptyHint('还没有收藏题目', '答题时点击收藏按钮即可添加')
    } else {
    List({ space: 10 }) {
    ForEach(this.favRecords, (record: FavoriteRecord) => {
    ListItem() {
    this.QuestionCard(record.questionId, record.bankId, record.createdAt, '')
    }
    .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE })
    }, (record: FavoriteRecord) => record.questionId)
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.Spring)
    .padding({ bottom: 16 })
    }
    }

    这里的空状态不是“暂无数据”四个字,而是告诉用户“答题时点击收藏按钮即可添加”。对学习应用来说,空状态应该指向下一步动作,避免用户误以为功能不可用。

    八、笔记列表复用题卡并附加编辑入口

    笔记列表的数据来自 noteRecords。每条笔记传入 QuestionCard 的 note.content,题卡内部会额外渲染笔记摘要和编辑按钮。

    ForEach(this.noteRecords, (note: NoteRecord) => {
    ListItem() {
    this.QuestionCard(note.questionId, note.bankId, note.updatedAt, note.content)
    }
    .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE })
    }, (note: NoteRecord) => note.questionId)

    题卡内部判断笔记内容是否存在:

    if (noteContent.length > 0) {
    Row({ space: 8 }) {
    Column()
    .width(3)
    .height(32)
    .borderRadius(1.5)
    .backgroundColor(Colors.PRIMARY)
    Text(noteContent)
    .fontSize(Sizes.CAPTION_FONT)
    .fontColor(Colors.TEXT_SECONDARY)
    .lineHeight(18)
    .maxLines(2)
    .textOverflow({ overflow: TextOverflow.Ellipsis })
    .layoutWeight(1)
    Text('编辑')
    .fontSize(Sizes.CAPTION_FONT)
    .fontColor(Colors.PRIMARY)
    .fontWeight(FontWeight.Medium)
    .padding({ left: 10, right: 10, top: 6, bottom: 6 })
    .borderRadius(14)
    .backgroundColor(Colors.PRIMARY_LIGHT)
    .onClick(() => { this.openEditNote(questionId, bankId, noteContent) })
    }
    }

    maxLines(2) 和省略号能防止长笔记把卡片撑得过高。移动端列表里,这类文本约束很必要。

    九、笔记编辑弹层只维护临时编辑态

    点击“编辑”后,收藏页会记录当前题目、题库和笔记内容,然后打开弹层。

    private openEditNote(questionId: string, bankId: string, content: string): void {
    this.editingQuestionId = questionId
    this.editingBankId = bankId
    this.editingNoteText = content
    this.showEditNote = true
    }

    保存时调用 UserDataManager.upsertNote,并把返回的新数组赋给 noteRecords。

    this.noteRecords = UserDataManager.upsertNote(
    this.noteRecords, this.editingQuestionId, this.editingBankId, this.editingNoteText)
    this.showEditNote = false

    这保证了弹层编辑态和真实列表态分离。用户输入过程中只改 editingNoteText,点击保存后才更新 noteRecords。如果用户点遮罩关闭弹层,列表不会被中途写入。

    十、错题列表提供批量练习和清空

    错题分栏和收藏、笔记不同,它额外提供“练习错题”和“清空”两个动作。

    Text('练习错题')
    .fontSize(Sizes.CAPTION_FONT)
    .fontColor(Color.White)
    .fontWeight(FontWeight.Medium)
    .padding({ left: 14, right: 14, top: 7, bottom: 7 })
    .borderRadius(16)
    .backgroundColor(Colors.PRIMARY)
    .onClick(() => {
    router.pushUrl({ url: 'pages/PracticePage', params: { bankId: this.wrongRecords[0].bankId, mode: 'wrong' } })
    })
    Text('清空')
    .fontSize(Sizes.CAPTION_FONT)
    .fontColor(Colors.ERROR)
    .padding({ left: 12, right: 0, top: 7, bottom: 7 })
    .onClick(() => { this.wrongRecords = UserDataManager.clearWrong() })

    这里有一个前置分支保护:只有 wrongRecords.length > 0 时才渲染这两个按钮。因此 this.wrongRecords[0].bankId 不会在空数组状态下执行。清空错题后,wrongRecords 变成空数组,页面下一次渲染就进入空状态。

    十一、题卡点击要尽量回到原题

    收藏页的题卡整体可点击。它会先找到当前题目,再把题型和起始题目传给 PracticePage。

    private findQuestion(questionId: string, bankId: string): Question | undefined {
    return getQuestions(bankId).find(q => q.id === questionId)
    }

    private practiceQuestion(questionId: string, bankId: string): void {
    const q = this.findQuestion(questionId, bankId)
    const qType: string = q ? q.type : ''
    try {
    router.pushUrl({
    url: 'pages/PracticePage',
    params: {
    bankId: bankId,
    mode: 'random',
    questionType: qType,
    startQuestionId: questionId
    }
    })
    } catch (_) {
    }
    }

    这里不是只传 bankId。如果收藏题还在题库中,附带 questionType 可以让练习页先进入同类型题池;附带 startQuestionId 可以让练习页优先把这道题放到前面。

    PracticePage 对 questionType 和 startQuestionId 有对应承接:

    if (this.mode === 'random' && params.questionType && params.questionType.length > 0) {
    const typedQuestions = this.questions.filter((q: Question) => q.type === params.questionType)
    if (typedQuestions.length > 0) {
    this.questions = typedQuestions
    }
    }
    this.questions = this.pickRandomPracticeQuestions(this.questions, 20, params.startQuestionId || '')

    这条链路能降低“点了收藏题却看到别的题”的概率。

    十二、题目缺失时要有兜底文案

    收藏记录里保存的是 questionId 和 bankId,而不是完整题干。渲染题卡时需要回查题目。

    private getStem(questionId: string, bankId: string): string {
    const q = this.findQuestion(questionId, bankId)
    return q ? q.stem : `题目 ${questionId}`
    }

    如果后续题库数据调整,某条旧收藏找不到原题,页面不会崩溃,而是显示 题目 ${questionId}。这不是最完美的业务体验,但在本地题库迭代中是必要兜底。

    十三、空状态组件统一三类列表

    EmptyHint 是三类列表共同使用的空状态组件。

    @Builder
    EmptyHint(msg: string, sub: string) {
    Column({ space: 8 }) {
    Image($r('app.media.img_empty_default'))
    .width(120)
    .height(120)
    .objectFit(ImageFit.Contain)
    .opacity(0.6)
    Text(msg)
    .fontSize(Sizes.BODY_FONT)
    .fontColor(Colors.TEXT_HINT)
    Text(sub)
    .fontSize(Sizes.CAPTION_FONT)
    .fontColor(Colors.TEXT_HINT)
    }
    .layoutWeight(1)
    .width('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    }

    三类文案不同,但视觉结构一致:

    分栏主文案副文案
    收藏 还没有收藏题目 答题时点击收藏按钮即可添加
    笔记 还没有写过笔记 答题时点击笔记按钮记录心得
    错题 暂无错题记录 答错的题会自动收录到这里

    这种统一能让学习夹保持一致,也避免每个列表各写一套空状态样式。

    十四、本地复核清单

    可以按下面清单复核源码与文章是否一致。

    复核点源码位置预期
    三类列表状态 FavoritePage.ets 顶部 @StorageLink 绑定三个记录数组
    分栏状态 favoriteTabIndex 外部页面可切换收藏页分栏
    收藏切换 PracticePage.ets 调用 UserDataManager.toggleFavorite
    列表刷新 FavList/NoteList/WrongList 直接使用当前数组长度和数组内容
    笔记编辑 EditNoteDialog 保存后 upsertNote 并关闭弹层
    错题清空 WrongList this.wrongRecords = UserDataManager.clearWrong()
    题卡跳转 practiceQuestion 传 bankId/questionType/startQuestionId
    空状态 EmptyHint 三类列表空时显示不同文案

    建议手工验证五条路径:

  • 在答题页收藏一道题,切到收藏页,收藏数量和列表应更新。
  • 再次取消收藏,收藏页应回到空状态或移除该题。
  • 给一道题写笔记,打开笔记分栏,能看到笔记摘要并可编辑。
  • 答错题后进入错题分栏,能看到错题数量,清空后显示空状态。
  • 点击收藏题卡,应进入 PracticePage,并尽量从该题开始。
  • 十五、常见问题与修复方向

    问题可能原因修复方向
    收藏后列表不刷新 原地修改数组,没有重新赋值 使用 toggleFavorite 返回新数组并赋给 favRecords
    顶部数量和列表不一致 单独维护计数状态 从数组长度派生计数
    点击题卡进入无关题 只传 bankId 同时传 questionType 和 startQuestionId
    笔记编辑未保存 只改临时编辑态 保存时调用 upsertNote 更新 noteRecords
    清空错题后仍显示旧列表 没把返回空数组赋值给状态 this.wrongRecords = UserDataManager.clearWrong()

    总结

    FavoritePage.ets 的核心不是“收藏按钮怎么画”,而是让收藏、笔记、错题三类学习记录围绕 AppStorage 做状态闭环。答题页通过 UserDataManager 修改数组并持久化,收藏页通过 @StorageLink 接收最新数组,根据当前分栏渲染列表或空状态,再通过题卡点击把用户带回练习页。

    这套实现适合当前笔下生辉的本地学习记录场景:不引入服务端,不伪造跨设备能力,只把本地收藏、笔记、错题做成可恢复、可刷新、可继续练习的闭环。后续如果要扩展账号同步,可以在 UserDataManager 后面替换 Repository 或同步服务,但收藏页的分栏、空状态和题卡跳转边界仍然可以保留。

    部分内容由AI辅助生成。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【笔下生辉|08】HarmonyOS ArkTS 收藏页实战:处理收藏切换、列表刷新和空状态
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!