HarmonyOS NEXT 新闻资讯应用 · 个人中心模块 features/personal 实现指南

开发环境:DevEco Studio 6.1.0 Release
SDK版本:HarmonyOS SDK 6.1.0(23) / API 23
开发语言:ArkTS
状态管理:V2(@ComponentV2系列装饰器)
前置阅读common 公共能力层指南 | 新闻模块指南 | 整体架构指南

本篇详细讲解个人中心模块 features/personal 的实现。作为应用最后一个 Tab 页,个人中心是逻辑最复杂的 feature 模块——包含 3 个组件/页面、404 行源码,涵盖用户头像展示、功能网格、登录验证码倒计时、评论列表、退出登录、AppStorage 跨页面状态共享 等核心功能。


效果


一、模块定位与架构角色

个人中心模块在三层架构中处于基础特性层(features),编译为 HAR 包:

产品定制层 product/phone (HAP)
    ↓ 依赖
基础特性层 features/news | features/video | features/live | features/personal ← 本模块 | features/service (HAR)
    ↓ 依赖
公共能力层 common (HAR)

模块职责:提供用户中心、登录和评论管理功能,包含 3 个组件/页面:

组件 文件 行数 类型 功能
PersonalHome components/PersonalHome.ets 208 @ComponentV2 个人中心主页,List顶部对齐+差异化图标+更多列表+退出登录
LoginPage pages/LoginPage.ets 139 @ComponentV2 登录页,手机号+验证码,NavDestination
MyComments pages/MyComments.ets 89 @ComponentV2 我的评论页,评论列表/空状态,NavDestination

被依赖关系product/phoneMainPagePersonalHome 嵌入第四个 HdsTab(“我的”),LoginPageMyComments 通过 pageMap 路由注册。


二、模块配置详解

2.1 oh-package.json5

{
  "name": "personal",
  "version": "1.0.0",
  "description": "Please describe the basic information.",
  "main": "Index.ets",
  "author": "",
  "license": "Apache-2.0",
  "dependencies": {
    "common": "file:../../common"
  }
}

2.2 Index.ets 入口文件

export { PersonalHome } from './src/main/ets/components/PersonalHome';
export { LoginPage } from './src/main/ets/pages/LoginPage';
export { MyComments } from './src/main/ets/pages/MyComments';

导出 3 个组件。MainPage 通过 import { PersonalHome, LoginPage, MyComments } from 'personal' 使用。


三、完整文件结构树

features/personal/
├── oh-package.json5                    (12行) 模块配置
├── Index.ets                            (4行)  统一导出
├── src/main/
│   ├── ets/
│   │   ├── components/
│   │   │   └── PersonalHome.ets         (208行) 个人中心主页,List+差异化图标
│   │   └── pages/
│   │       ├── LoginPage.ets            (139行) 登录页
│   │       └── MyComments.ets           (89行)  我的评论页
│   └── resources/                       (图片等静态资源)

四、PersonalHome 个人中心主页

PersonalHome 是个人中心模块的核心组件,包含头像区、功能网格、更多功能列表和退出登录按钮。

4.1 完整源码

重要变更:PersonalHome 做了以下关键调整:

  1. 顶部对齐Scroll 改为 List + .alignListItem(ListItemAlign.Start)
  2. 差异化图标:功能网格 4 项各自使用不同 SVG 图标(ic_add/ic_history/ic_comment/ic_collect)
  3. 前置图标:更多功能列表每项前添加对应 SVG 图标(ic_activity/service/ic_fankui 等)
import { UserInfo, StyleConstants } from 'common';

@ComponentV2
export struct PersonalHome {
  @Local user: UserInfo = new UserInfo();
  @Consumer('pageStack') pageStack!: NavPathStack;
  private functionItems: string[] = ['订阅', '历史', '评论', '收藏'];
  private moreItems: string[] = ['活动', '服务', '反馈', '消息', '客服', '关于', '设置'];

  // 功能网格图标映射
  getFunctionIcon(item: string): Resource {
    if (item === '订阅') return $r('app.media.ic_add');
    else if (item === '历史') return $r('app.media.ic_history');
    else if (item === '评论') return $r('app.media.ic_comment');
    else return $r('app.media.ic_collect');
  }

  // 更多功能图标映射
  getMoreIcon(item: string): Resource {
    if (item === '活动') return $r('app.media.ic_activity');
    else if (item === '服务') return $r('app.media.service');
    else if (item === '反馈') return $r('app.media.ic_fankui');
    else if (item === '消息') return $r('app.media.ic_message');
    else if (item === '客服') return $r('app.media.ic_kefu');
    else if (item === '关于') return $r('app.media.ic_about');
    else return $r('app.media.ic_set');
  }

  @Builder AvatarSection() { /* 头像区,同之前 */ }

  build() {
    List() {                          // List 替代 Scroll,实现顶部对齐
      ListItem() {                    // 红色顶部区域
        Column() { this.AvatarSection() }
          .width('100%').backgroundColor('#E84026').padding({ top: 8 })
      }

      ListItem() {                    // 功能网格 - 差异化图标
        Row() {
          ForEach(this.functionItems, (item: string) => {
            Column({ space: 8 }) {
              Image(this.getFunctionIcon(item))  // 各自不同图标
                .width(28).height(28).fillColor('#E84026')
              Text(item).fontSize(13)
            }.layoutWeight(1).height(80).justifyContent(FlexAlign.Center)
          })
        }.backgroundColor(Color.White)
          .borderRadius({ topLeft: 16, topRight: 16 }).padding({ top: 16, bottom: 8 })
      }

      ListItem() {                    // 更多功能 - 带前置图标
        Column() {
          ForEach(this.moreItems, (item: string) => {
            Row() {
              Image(this.getMoreIcon(item))     // 前置图标 20×20
                .width(20).height(20).margin({ right: 12 })
              Text(item).fontSize(15)
              Blank()
              Text('>').fontSize(16).fontColor('#CCCCCC')
            }.width('100%').height(48).padding({ left: 16, right: 16 })
          })
        }.backgroundColor(Color.White).borderRadius(8)
          .margin({ top: 12, left: 16, right: 16 })
      }

      if (this.user.isLoggedIn) {     // 退出登录按钮
        ListItem() {
          Row() {
            Button('退出登录', { type: ButtonType.Capsule })
              .width('60%').height(40)
              .onClick(() => { /* 清除登录态 */ })
          }.width('100%').justifyContent(FlexAlign.Center)
            .padding({ top: 24, bottom: 24 })
        }
      }
    }
    .alignListItem(ListItemAlign.Start)   // 关键:顶部对齐
    .scrollBar(BarState.Off)
    .width('100%').height('100%')
    .backgroundColor(StyleConstants.BG_COLOR)
  }
}

4.2 分段深度讲解

段落1 — 状态变量(第10-13行)
@Local user: UserInfo = new UserInfo();
@Consumer('pageStack') pageStack!: NavPathStack;
private functionItems: string[] = ['订阅', '历史', '评论', '收藏'];
private moreItems: string[] = ['活动', '服务', '反馈', '消息', '客服', '关于', '设置'];
变量 装饰器 类型 说明
user @Local UserInfo 用户信息模型(@ObservedV2 类)
pageStack @Consumer('pageStack') NavPathStack 路由栈,用于跳转 LoginPage 和 MyComments
functionItems 无(private) string[] 功能网格 4 项
moreItems 无(private) string[] 更多功能 7 项

UserInfo 模型回顾

@ObservedV2
export class UserInfo {
  @Trace account: string = '';
  @Trace nickname: string = '';
  @Trace isLoggedIn: boolean = false;
  @Trace score: number = 0;
}

isLoggedIn 默认为 false,在 aboutToAppear 中根据 AppStorage 是否有 userAccount 来决定。

段落2 — aboutToAppear 登录态恢复(第15-23行)
aboutToAppear() {
  let account = AppStorage.get<string>('userAccount') ?? '';
  if (account !== '') {
    this.user.account = account;
    this.user.nickname = `用户${account.slice(-4)}`;
    this.user.isLoggedIn = true;
    this.user.score = 100;
  }
}

AppStorage.get 读取登录态

  1. AppStorage.get<string>('userAccount') — 从全局存储读取手机号
  2. ?? '' — 空值合并,如果不存在则返回空字符串
  3. account !== '' — 有手机号说明已登录(由 LoginPage 写入)
  4. account.slice(-4) — 取手机号后 4 位,生成昵称如"用户1234"
  5. 设置 isLoggedIn = truescore = 100

数据流LoginPageAppStorage.setOrCreate('userAccount', phone)PersonalHome.aboutToAppear()AppStorage.get('userAccount')

段落3 — @Builder AvatarSection 头像区(第25-68行)

布局结构

Column (居中对齐)
├── Stack (头像)
│   ├── Circle (64×64, 灰色背景 #F5F5F5)
│   └── Text (首字母/问号, 28号加粗)
├── Text (用户名/登录注册, 18号白色)
│   └── onClick → pushPathByName('LoginPage')
└── if/else
    ├── 已登录:Text('积分:100')
    └── 未登录:Text('登录后查看积分')

条件渲染设计

状态 头像文字 文字颜色 用户名 积分
已登录 nickname.charAt(0) 首字母 #E84026 红色 用户1234 积分:100
未登录 '?' #999999 灰色 登录/注册 登录后查看积分

头像实现技巧:使用 Stack 叠加 Circle(灰色背景圆)和 Text(首字母),模拟头像效果。charAt(0) 取昵称第一个字符作为头像文字。

点击跳转登录:用户名 TextonClick 判断 !this.user.isLoggedIn,仅在未登录时跳转到 LoginPage

段落4 — 功能网格 + 差异化图标

功能网格现在为每个功能项使用独立的 SVG 图标,通过 getFunctionIcon() 方法映射:

功能项 图标资源 语义
订阅 ic_add.svg 添加/订阅
历史 ic_history.svg 历史记录
评论 ic_comment.svg 评论/消息
收藏 ic_collect.svg 收藏/星形
Image(this.getFunctionIcon(item))    // 差异化图标
  .width(28).height(28)
  .fillColor('#E84026')

所有 SVG 图标从源代码目录复制到 features/personal/src/main/resources/base/media/

段落5 — 更多功能列表 + 前置图标

更多功能列表现在为每个列表项添加前置 SVG 图标,通过 getMoreIcon() 方法映射:

列表项 图标资源
活动 ic_activity.svg
服务 service.svg
反馈 ic_fankui.svg
消息 ic_message.svg
客服 ic_kefu.svg
关于 ic_about.svg
设置 ic_set.svg
Row() {
  Image(this.getMoreIcon(item))          // 前置图标 20×20
    .width(20).height(20).margin({ right: 12 })
  Text(item).fontSize(15)
  Blank()
  Text('>').fontSize(16).fontColor('#CCCCCC')
}
段落6 — build() 主布局 + 顶部对齐(List 替代 Scroll)

关键变更:使用 List 替代 Scroll,配合 .alignListItem(ListItemAlign.Start) 实现内容顶部对齐:

build() {
  List() {
    ListItem() { /* 头像区 */ }
    ListItem() { /* 功能网格 */ }
    ListItem() { /* 更多功能 */ }
    if (isLoggedIn) { ListItem() { /* 退出登录 */ } }
  }
  .alignListItem(ListItemAlign.Start)   // 关键:顶部对齐
  .scrollBar(BarState.Off)
}

为什么用 List 而非 Scroll?

对比 Scroll + Column List + ListItem
顶部对齐 内容可能垂直居中 ListItemAlign.Start 确保顶部对齐
性能 一次性创建所有子组件 ListItem 懒加载渲染
可扩展性 需手动管理滚动 支持 .scrollTo()scrollBar

退出登录按钮

Button('退出登录', { type: ButtonType.Capsule })
  .width('60%')
  .height(40)
  .fontColor('#E84026')                    // 红色文字
  .backgroundColor(Color.White)            // 白色背景
  .border({ width: 1, color: '#E84026' })  // 红色边框
  .onClick(() => {
    AppStorage.setOrCreate('userAccount', '');  // 清除全局存储
    this.user.account = '';                     // 重置所有属性
    this.user.nickname = '';
    this.user.isLoggedIn = false;
    this.user.score = 0;
  })

ButtonType.Capsule:胶囊形按钮(两端半圆形),比默认的 ButtonType.Normal(圆角矩形)更适合"退出登录"这类操作按钮。

退出登录逻辑

  1. AppStorage.setOrCreate('userAccount', '') — 清除全局手机号
  2. 重置 user 对象所有属性
  3. isLoggedIn = false 触发条件渲染更新:头像变"?“、用户名变"登录/注册”、退出按钮隐藏

条件渲染if (this.user.isLoggedIn) 只在已登录时显示退出按钮,未登录时不显示。


五、LoginPage 登录页

LoginPage 是从 PersonalHome 点击"登录/注册"跳转的二级页面,支持手机号 + 验证码登录。

5.1 完整源码

import { StyleConstants, Logger } from 'common';

/**
 * 登录页
 * 使用 @ComponentV2 + NavDestination 实现
 * 支持手机号+验证码登录
 */
@ComponentV2
export struct LoginPage {
  @Consumer('pageStack') pageStack!: NavPathStack;
  @Local phone: string = '';
  @Local verifyCode: string = '';
  @Local isCodeSent: boolean = false;
  @Local countdown: number = 0;

  private verifyCodes: string[] = ['1234', '5678', '9012', '3456'];

  @Builder
  InputRow(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
    Row() {
      Text(label)
        .fontSize(14)
        .fontColor(StyleConstants.TEXT_PRIMARY)
        .width(80)

      TextInput({ placeholder: placeholder, text: value })
        .layoutWeight(1)
        .height(40)
        .type(label === '手机号' ? InputType.Number : InputType.Normal)
        .onChange(onChange)
    }
    .width('100%')
    .height(56)
    .padding({ left: 16, right: 16 })
    .border({ width: { bottom: 0.5 }, color: StyleConstants.DIVIDER_COLOR })
  }

  build() {
    NavDestination() {
      Column() {
        // 顶部返回栏
        Row() {
          Image($r('app.media.back'))
            .width(24).height(24)
            .margin({ left: 16 })
            .onClick(() => { this.pageStack.pop(); })
        }
        .width('100%').height(56).padding({ top: 8 })

        // 标题
        Text('手机号登录')
          .fontSize(24).fontWeight(FontWeight.Bold)
          .fontColor(StyleConstants.TEXT_PRIMARY)
          .margin({ left: 24, top: 24 }).width('100%')

        Text('未注册的手机号验证后将自动创建账号')
          .fontSize(13).fontColor(StyleConstants.TEXT_SECONDARY)
          .margin({ left: 24, top: 8 }).width('100%')

        // 输入区域
        Column() {
          this.InputRow('手机号', '请输入手机号', this.phone, (v: string) => {
            this.phone = v;
          })

          Row() {
            Text('验证码')
              .fontSize(14).fontColor(StyleConstants.TEXT_PRIMARY).width(80)

            TextInput({ placeholder: '请输入验证码', text: this.verifyCode })
              .layoutWeight(1).height(40)
              .type(InputType.Number)
              .onChange((v: string) => { this.verifyCode = v; })

            Button(this.countdown > 0 ? `${this.countdown}s` : '获取验证码')
              .height(32).fontSize(12)
              .fontColor(this.isCodeSent && this.countdown > 0 ? '#999999' : '#E84026')
              .backgroundColor(Color.Transparent)
              .enabled(!this.isCodeSent || this.countdown <= 0)
              .onClick(() => {
                if (this.phone.length === 11) {
                  this.isCodeSent = true;
                  this.countdown = 60;
                  let code = this.verifyCodes[Math.floor(Math.random() * this.verifyCodes.length)];
                  Logger.info(`验证码为:${code}`);
                  let timer = setInterval(() => {
                    this.countdown--;
                    if (this.countdown <= 0) {
                      clearInterval(timer);
                    }
                  }, 1000);
                }
              })
          }
          .width('100%').height(56)
          .padding({ left: 16, right: 16 })
          .border({ width: { bottom: 0.5 }, color: StyleConstants.DIVIDER_COLOR })
        }
        .margin({ top: 24, left: 8, right: 8 })

        // 登录按钮
        Button('登录', { type: ButtonType.Capsule })
          .width('80%').height(44).fontSize(16)
          .fontColor(Color.White)
          .backgroundColor('#E84026')
          .margin({ top: 40 })
          .enabled(this.phone.length === 11 && this.verifyCode.length >= 4)
          .onClick(() => {
            AppStorage.setOrCreate('userAccount', this.phone);
            this.pageStack.pop();
          })
      }
      .width('100%').height('100%')
      .backgroundColor(Color.White)
    }
    .hideTitleBar(true)
  }
}

5.2 分段深度讲解

段落1 — 状态变量(第11-16行)
@Local phone: string = '';           // 手机号
@Local verifyCode: string = '';      // 验证码
@Local isCodeSent: boolean = false;  // 是否已发送验证码
@Local countdown: number = 0;        // 倒计时秒数
private verifyCodes: string[] = ['1234', '5678', '9012', '3456'];  // Mock验证码池

4 个 @Local 状态变量控制整个登录页的交互逻辑。

段落2 — @Builder InputRow 参数化输入行(第18-36行)
@Builder
InputRow(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
  Row() {
    Text(label)                                    // 标签:80宽固定
      .width(80)
    TextInput({ placeholder: placeholder, text: value })  // 输入框
      .layoutWeight(1)                             // 占满剩余宽度
      .type(label === '手机号' ? InputType.Number : InputType.Normal)
      .onChange(onChange)                          // 回调
  }
  .border({ width: { bottom: 0.5 }, color: StyleConstants.DIVIDER_COLOR })  // 底部边框
}

4 个参数

  • label:标签文字(“手机号"或"验证码”)
  • placeholder:输入框占位文字
  • value:当前输入值
  • onChange:值变化回调函数 (v: string) => void

动态键盘类型label === '手机号' 时使用 InputType.Number(数字键盘),否则使用 InputType.Normal(全键盘)。

调用示例

this.InputRow('手机号', '请输入手机号', this.phone, (v: string) => {
  this.phone = v;  // 回调中更新状态
})
段落3 — 验证码倒计时(第95-109行)
.onClick(() => {
  if (this.phone.length === 11) {            // 1. 验证手机号长度
    this.isCodeSent = true;                  // 2. 标记已发送
    this.countdown = 60;                     // 3. 开始60秒倒计时
    let code = this.verifyCodes[             // 4. 随机选一个验证码
      Math.floor(Math.random() * this.verifyCodes.length)
    ];
    Logger.info(`验证码为:${code}`);         // 5. 输出到日志
    let timer = setInterval(() => {          // 6. 每秒递减
      this.countdown--;
      if (this.countdown <= 0) {
        clearInterval(timer);                // 7. 到0时停止
      }
    }, 1000);
  }
})

倒计时完整流程

用户点击"获取验证码"
  → 验证手机号 11 位
  → isCodeSent = true
  → countdown = 60
  → 按钮文字变为 "60s"
  → setInterval 每秒执行:
      countdown--
      按钮文字变为 "59s" → "58s" → ... → "1s" → "0s"
      countdown <= 0 时:
        clearInterval(timer)
        按钮文字恢复为 "获取验证码"
        按钮 enabled 恢复为 true

按钮 enabled 条件!this.isCodeSent || this.countdown <= 0

  • 未发送过验证码时:!false = true → 可点击
  • 已发送且倒计时中:!true = false → 不可点击
  • 倒计时结束:countdown <= 0 = true → 可再次点击
段落4 — 登录按钮(第118-130行)
Button('登录', { type: ButtonType.Capsule })
  .enabled(this.phone.length === 11 && this.verifyCode.length >= 4)
  .onClick(() => {
    AppStorage.setOrCreate('userAccount', this.phone);  // 写入全局存储
    this.pageStack.pop();                                // 返回上一页
  })

enabled 条件:手机号 11 位 + 验证码至少 4 位,两个条件同时满足才可点击。

AppStorage.setOrCreate:如果 'userAccount' key 已存在则更新值,不存在则创建。写入手机号后,PersonalHome 的 aboutToAppear 可以读取到。


六、MyComments 我的评论页

MyComments 展示用户发表的所有评论,从 AppStorage 读取评论数据。

6.1 完整源码

import { StyleConstants } from 'common';

/**
 * 我的评论页
 * 使用 @ComponentV2 + NavDestination 实现
 * 展示用户发表的所有评论
 */
@ComponentV2
export struct MyComments {
  @Consumer('pageStack') pageStack!: NavPathStack;
  @Local comments: string[] = [];

  aboutToAppear() {
    this.comments = (AppStorage.get<string[]>('newsData') ?? []);
  }

  build() {
    NavDestination() {
      Column() {
        // 顶部标题栏
        Row() {
          Image($r('app.media.back'))
            .width(24).height(24).margin({ left: 16 })
            .onClick(() => { this.pageStack.pop(); })

          Text('我的评论')
            .fontSize(18).fontWeight(FontWeight.Bold)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .margin({ right: 40 })
        }
        .width('100%').height(56).padding({ top: 8 })

        if (this.comments.length === 0) {
          // 空状态
          Column({ space: 12 }) {
            Text('暂无评论')
              .fontSize(16).fontColor(StyleConstants.TEXT_SECONDARY)
            Text('去新闻详情页发表评论吧')
              .fontSize(13).fontColor('#CCCCCC')
          }
          .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
        } else {
          // 评论列表
          List() {
            ForEach(this.comments, (item: string, index?: number) => {
              ListItem() {
                Column() {
                  Text(item)
                    .fontSize(15).fontColor(StyleConstants.TEXT_PRIMARY).width('100%')
                  Text('刚刚')
                    .fontSize(12).fontColor(StyleConstants.TEXT_SECONDARY).margin({ top: 6 })
                }
                .padding({ left: 16, right: 16, top: 12, bottom: 12 })

                Divider()
                  .backgroundColor(StyleConstants.DIVIDER_COLOR)
                  .margin({ left: 16, right: 16 })
              }
            }, (item: string, index?: number) => item + index)
          }
          .width('100%').layoutWeight(1)
        }
      }
      .width('100%').height('100%')
      .backgroundColor(Color.White)
    }
    .hideTitleBar(true)
  }
}

6.2 分段深度讲解

段落1 — 数据加载(第13-15行)
aboutToAppear() {
  this.comments = (AppStorage.get<string[]>('newsData') ?? []);
}

AppStorage 读取 key 为 'newsData' 的评论数组。如果不存在(用户从未评论过),返回空数组 []newsDataNewsDetail 页面的评论功能写入。

段落2 — 条件渲染:空状态 vs 列表态(第41-80行)
if (this.comments.length === 0) {
  // 空状态:居中显示提示
  Column({ space: 12 }) {
    Text('暂无评论')
    Text('去新闻详情页发表评论吧')
  }
  .layoutWeight(1)                   // 占满剩余空间
  .justifyContent(FlexAlign.Center)  // 垂直居中
} else {
  // 评论列表
  List() {
    ForEach(this.comments, ...)
  }
  .layoutWeight(1)
}

条件渲染模式:ArkUI 的 if/else 控制组件的创建和销毁:

  • 空状态:Column 居中显示两行提示文字
  • 列表态:List + ForEach 渲染评论列表

ForEach key 生成器item + index 将评论内容和索引拼接,确保唯一性(同一条评论可能发表多次)。


七、AppStorage 跨页面状态共享模式详解

个人中心模块大量使用 AppStorage 实现跨页面数据传递,是本应用中最集中的 AppStorage 使用场景。

7.1 数据流图

┌──────────────────────────────────────────────────────┐
│                    AppStorage                         │
│                                                      │
│  'userAccount': string  ← LoginPage 写入手机号        │
│                       → PersonalHome 读取恢复登录态    │
│                                                      │
│  'newsData': string[]   ← NewsDetail 写入评论内容     │
│                       → MyComments 读取展示列表       │
└──────────────────────────────────────────────────────┘

7.2 AppStorage API 说明

API 用途 示例
setOrCreate(key, value) 存在则更新,不存在则创建 AppStorage.setOrCreate('userAccount', phone)
get<T>(key) 读取值,不存在返回 undefined AppStorage.get<string>('userAccount')
?? 空值合并 undefined 时使用默认值 AppStorage.get<string>('userAccount') ?? ''

7.3 为什么用 AppStorage 而非 @Provider/@Consumer?

对比维度 AppStorage @Provider/@Consumer
作用范围 全局(跨 Navigation 边界) 组件树内(父子关系)
数据类型 基本类型 + 数组 任意类型
适用场景 登录态、用户设置等全局状态 NavPathStack 等组件树共享状态
生命周期 应用全局 组件树范围
本应用使用 userAccount、newsData pageStack

关键区别:LoginPage 和 PersonalHome 不在同一个父子组件链中——它们通过 NavPathStack 路由跳转,处于 Navigation 容器的不同层级。AppStorage 可以跨越这种边界,而 @Provider/@Consumer 需要严格的父子关系。


八、V2 装饰器使用汇总

装饰器 变量/位置 作用
@ComponentV2 PersonalHomeLoginPageMyComments V2 组件声明
@Local userphoneverifyCodeisCodeSentcountdowncomments 组件内状态
@Consumer('pageStack') PersonalHome(组件内) 消费 NavPathStack
@Param LoginPage.pageStackMyComments.pageStack MainPage 显式传参
@Builder AvatarSectionInputRow 可复用 UI 模板

九、常见问题 Q&A

Q1: LoginPage 的验证码是真的发送了吗?

A: 不是。验证码从本地数组 ['1234', '5678', '9012', '3456'] 中随机选取,通过 Logger.info 输出到日志。实际项目中应调用短信 API 发送。

Q2: 退出登录后再次进入个人中心,状态如何?

A: 退出登录将 'userAccount' 设为空字符串。下次进入个人中心时,aboutToAppear 读取到空字符串,isLoggedIn 保持 false,显示未登录状态。

Q3: 为什么 PersonalHome 用 List 而非 Scroll?

A: Scroll 包裹 Column 时内容可能垂直居中而非顶部对齐。改用 List + .alignListItem(ListItemAlign.Start) 确保内容始终从顶部开始渲染。

Q4: MyComments 页面的评论数据何时更新?

A: 仅在 aboutToAppear 时读取一次。如果用户在 NewsDetail 新增评论后返回 MyComments,需要重新触发页面创建才能看到新数据。实际项目中可以使用 AppStorage.link 实现实时同步。

Q5: 如何添加更多功能项的 onClick 跳转?

A: 在 FunctionGrid 的 onClick 中添加条件分支:

.onClick(() => {
  if (item === '评论') { this.pageStack.pushPathByName('MyComments', null); }
  else if (item === '收藏') { this.pageStack.pushPathByName('MyFavorites', null); }
  // ...
})

十、小结

个人中心模块以 208 行源码实现了完整的用户中心功能,核心知识点包括:

  1. List + alignListItem 顶部对齐:替代 Scroll,确保页面内容始终顶部对齐
  2. 差异化图标映射getFunctionIcon() / getMoreIcon() 为每个功能项返回独立 SVG 图标
  3. AppStorage 跨页面状态共享:LoginPage 写入 → PersonalHome 读取
  4. 验证码倒计时setInterval + countdown 递减 + clearInterval 停止
  5. @Builder 参数化模板InputRow 接受 4 个参数(含回调函数),实现输入行复用
  6. 条件渲染if/else 控制头像区、退出按钮、空状态/列表态的显示

Logo

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

更多推荐