零基础鸿蒙应用开发第三十四节:MVVM架构下的商品管理登录页

零基础鸿蒙应用开发学习计划表

【学习目标】

  1. 掌握鸿蒙工程化开发的核心规范:静态工具类的统一导入与使用,单例模式ViewModel的设计与应用(核心服务于MVVM分层解耦);
  2. 深刻理解MVVM分层核心思想:明确数据模型(Model)、视图(View)、视图模型(ViewModel)的职责边界,并落地到登录功能开发中;
  3. 掌握基于MVVM的登录功能完整实现流程:正则校验、本地JSON数据读取、账号密码对比、页面路由跳转、登录状态持久化与恢复;
  4. 深化对ArkTS语法的综合应用:异步逻辑(async/await)、上下文(Context)、AppStorage状态管理的正确使用(为MVVM层间交互提供技术支撑);
  5. 具备MVVM架构下的工程问题排查能力:解决导入路径错误、数据同步异常、路由配置失效、单例方法调用异常等常见问题。

【学习重点】

  1. MVVM分层设计落地(核心)
    • Model层:极简的接口设计(Account/ValidateResult),仅承载数据、无任何业务逻辑;
    • ViewModel层:单例模式封装所有登录业务逻辑(校验+数据交互+状态存储/读取),作为View和Model的中间层;
    • View层:仅负责UI渲染和用户交互,通过调用ViewModel方法完成业务操作,不包含任何核心逻辑;
  2. 核心功能实现:正则校验(手机号+密码)、本地JSON异步读取、router页面跳转、登录状态持久化(沙箱+AppStorage)、启动页自动校验登录状态;
  3. 常见问题解决:Context导入与使用、AppStorage状态同步、异步异常捕获(try/catch)、沙箱文件读写、单例方法的正确调用;
  4. 工程化细节:统一常量管理、工具类复用、目录结构设计,区分rawfile(静态预设数据)和沙箱(动态持久化数据)的使用场景。

一、工程准备

  1. 复制第二十九节项目ClassObjectDemo_8,重命名为ClassObjectDemo_9
  2. 将上一节中的JsonUtil.ets拷贝到utils目录下,并补充沙箱文件写入方法;
  3. 新建页面:
    • 新建GoodsPage.ets:通过New→Page→Empty Page创建,文件名称填写GoodsPage;将原Index.ets内容迁移至此并修改结构体名为struct GoodsPage
    • 删除原Index.ets,重新创建新的Index.ets作为启动引导页;
    • 新建LoginPage.ets:通过New→Page→Empty Page创建,作为登录页面;
  4. 新增constants目录及AppStorageKey.ets全局常量文件;
  5. 项目完整目录(聚焦核心文件,贴合MVVM分层):
ClassObjectDemo_9/
├── entry/
│   ├── src/
│   │   ├── main/
│   │   │   ├── ets/
│   │   │   │   ├── constants/              # 应用全局常量(跨层复用)
│   │   │   │   │   ├── AppStorageKey.ets   # 登录状态常量
│   │   │   │   ├── utils/                  # 通用工具类(ViewModel层依赖)
│   │   │   │   │   ├── JsonUtil.ets        # JSON读取/写入工具类
│   │   │   │   │   └── ValidationUtil.ets  # 正则校验工具类
│   │   │   │   ├── model/                  # Model层:仅定义数据结构,无业务逻辑
│   │   │   │   │   └── login/              # 登录业务子模型
│   │   │   │   │       ├── Account.ets     # 账号数据模型
│   │   │   │   │       └── ValidateResult.ets # 验证结果数据模型
│   │   │   │   ├── viewmodel/              # ViewModel层:封装所有业务逻辑
│   │   │   │   │   └── LoginViewModel.ets  # 登录业务视图模型(单例)
│   │   │   │   ├── pages/                  # View层:仅负责UI交互
│   │   │   │   │   ├── Index.ets           # 启动引导页(判断登录状态)
│   │   │   │   │   ├── LoginPage.ets       # 登录页(UI交互)
│   │   │   │   │   └── GoodsPage.ets       # 商品管理页
│   │   │   ├── resources/                  # 资源模块
│   │   │   │   ├── base/profile/
│   │   │   │   │   └── main_pages.json     # 页面路由配置
│   │   │   │   └── rawfile/                # 本地静态JSON目录(Model层数据源)
│   │   │   │       └── user_info.json      # 预设登录账号数据

二、全局常量

2.1 AppStorageKey.ets(登录状态常量)

// constants/AppStorageKey.ets
/**
 * 全局AppStorage键值常量:统一管理状态标识,避免硬编码
 * MVVM场景下:用于View层和ViewModel层的状态同步
 */
class AppStorageKey {
  /** 登录状态标识 */
  static readonly IS_LOGIN: string = 'IS_LOGIN';
}

export default AppStorageKey;

三、工具类模块

3.1 JsonUtil.ets 复用上一节未变更不展示。

3.2 ValidationUtil.ets(正则校验工具)

// utils/ValidationUtil.ets
/**
 * 正则校验工具类:专注登录场景的格式校验
 * MVVM场景下:作为ViewModel层的工具依赖,仅提供校验能力,不参与业务决策
 */
export class ValidationUtil {
  /**
   * 校验手机号(11位数字,以13-9开头)
   * @param phone 手机号字符串
   * @returns 校验结果(true=合法)
   */
  public static validatePhone(phone: string): boolean {
    const phoneReg = /^1[3-9]\d{9}$/;
    return phoneReg.test(phone);
  }

  /**
   * 校验密码(8-20位,含数字+字母,区分大小写)
   * @param password 密码字符串
   * @returns 校验结果(true=合法)
   */
  public static validatePassword(password: string): boolean {
    const pwdReg = /^(?=.*\d)(?=.*[a-zA-Z]).{8,20}$/;
    return pwdReg.test(password);
  }
}

四、数据模型模块:MVVM的Model层(仅承载数据)

4.1 Account.ets(账号模型)

// model/login/Account.ets
/**
 * 账号模型(MVVM-Model层):仅承载登录所需核心字段,无任何业务逻辑
 * 职责边界:只定义数据结构,不包含校验、存储、交互等逻辑
 */
export interface Account {
  phone: string;  // 手机号
  password: string; // 密码
}

4.2 ValidateResult.ets(验证结果模型)

// model/login/ValidateResult.ets
/**
 * 登录验证结果模型(MVVM-Model层):统一返回格式,便于View层处理
 * 职责边界:仅定义结果数据结构,由ViewModel层赋值,View层读取展示
 */
export interface ValidateResult {
  success: boolean; // 验证是否通过
  message: string;  // 提示信息(成功/失败原因)
}

五、视图模型模块:MVVM的ViewModel层(核心业务逻辑封装)

设计成单例模式的原因:登录信息的读取、存储、退出登录等操作需全局统一状态,单例模式能保证ViewModel实例唯一,避免多实例导致的状态不一致,符合MVVM中ViewModel作为“唯一业务逻辑入口”的设计原则。

// viewmodel/LoginViewModel.ets

import { ValidationUtil } from "../utils/ValidationUtil";
import { JsonUtil } from "../utils/JsonUtil";
import { ValidateResult } from "../model/login/ValidateResult";
import { Account } from "../model/login/Account";
import { Context } from '@kit.AbilityKit';
import AppStorageKey from "../constants/AppStorageKey";

/**
 * 登录视图模型(MVVM-ViewModel层):单例模式
 * 核心职责:封装所有登录业务逻辑,作为View层和Model层的中间层
 * 边界:不涉及任何UI渲染,仅处理数据校验、存储、交互,通过统一格式返回结果给View层
 */
export class LoginViewModel {
  // 静态私有实例(保证全局唯一)
  private static instance: LoginViewModel;
  // 实例级账号信息(非静态):仅在ViewModel内部维护,View层无需感知
  private currentAccount: Account = { phone: '', password: '' };

  // 私有构造函数(禁止外部new创建实例):保证单例唯一性
  private constructor() {}

  // 静态方法:获取全局唯一实例
  public static getInstance(): LoginViewModel {
    if (!LoginViewModel.instance) {
      LoginViewModel.instance = new LoginViewModel();
    }
    return LoginViewModel.instance;
  }

  /**
   * 实例方法:更新账号信息并自动清洗数据
   * 职责:接收View层传递的原始数据,做前置清洗,不涉及UI
   * @param account 页面输入的账号数据
   */
  updateAccount(account: Account): void {
    this.currentAccount.phone = account.phone.trim();
    this.currentAccount.password = account.password.trim();
  }

  /**
   * 实例方法:验证登录信息(核心业务逻辑)
   * 职责:整合校验、数据读取、对比逻辑,返回标准化结果给View层
   * @param context 应用上下文
   * @returns 统一的验证结果(Model层结构)
   */
  async validateLogin(context: Context): Promise<ValidateResult> {
    const phone = this.currentAccount.phone;
    const password = this.currentAccount.password;

    // 空值校验
    if (!phone) return { success: false, message: '请输入手机号' };
    if (!password) return { success: false, message: '请输入密码' };

    // 格式校验
    if (!ValidationUtil.validatePhone(phone)) {
      return { success: false, message: '手机号格式错误(11位数字,以13-9开头)' };
    }
    if (!ValidationUtil.validatePassword(password)) {
      return { success: false, message: '密码格式错误(8-20位,含数字+字母)' };
    }

    // 本地数据对比(模拟服务器校验)
    try {
      const accountJson: string = await JsonUtil.readRawFileJson(context, 'user_info.json');
      const userData: Account = JSON.parse(accountJson);

      const userPhone = userData.phone;
      const userPassword = userData.password;

      if (!userPhone || !userPassword) {
        return { success: false, message: '用户数据格式错误' };
      }

      // 账号密码匹配:核心业务判断,仅返回结果
      if (phone === userPhone && password === userPassword) {
        await this.saveLoginState(context, userData);
        return { success: true, message: '登录成功' };
      } else {
        return { success: false, message: '手机号或密码错误' };
      }
    } catch (error) {
      return { success: false, message: `登录失败:${(error as Error).message}` };
    }
  }

  /**
   * 实例方法:存储登录状态(沙箱+AppStorage)
   * 职责:处理登录状态持久化,完成Model层数据到存储介质的交互
   * @param context 应用上下文
   * @param account 登录成功的账号信息
   * @returns 存储结果
   */
  async saveLoginState(context: Context, account: Account): Promise<boolean> {
    try {
      // 仅限于基础阶段我们演示登录。基础阶段用沙箱文件是为了降低学习难度,preferences用户首选项存储数据我们还没接触;
      await JsonUtil.writeSandboxJson(context, 'user_account.json', account);
      // AppStorage为系统内置,直接使用:实现ViewModel层到View层的状态同步
      AppStorage.setOrCreate(AppStorageKey.IS_LOGIN, true);
      console.log(`用户登录成功,信息已存储:${JSON.stringify(account)}`);
      return true;
    } catch (error) {
      console.error(`存储登录状态失败:${(error as Error).message}`);
      return false;
    }
  }

  /**
   * 实例方法:读取登录状态(从沙箱恢复)
   * 职责:初始化登录状态,为View层的页面跳转提供数据支撑
   * @param context 应用上下文
   */
  async readLoginState(context: Context): Promise<void> {
    try {
      const accountJson = await JsonUtil.readSandboxJson(context, 'user_account.json');
      if (accountJson) {
        // AppStorage为系统内置,直接使用:同步状态到View层
        AppStorage.setOrCreate(AppStorageKey.IS_LOGIN, true);
        console.log('检测到已登录状态,自动恢复');
      } else {
        AppStorage.setOrCreate(AppStorageKey.IS_LOGIN, false);
        console.log('未检测到登录状态');
      }
    } catch (error) {
      console.error(`读取登录状态失败:${(error as Error).message}`);
      AppStorage.setOrCreate(AppStorageKey.IS_LOGIN, false);
    }
  }
}

六、视图模块:MVVM的View层

6.1 路由配置(main_pages.json)

文件路径:src/main/resources/base/profile/main_pages.json

{
  "src": [
    "pages/Index",
    "pages/LoginPage",
    "pages/GoodsPage"
  ]
}

配置完成后点击DevEco Studio右上角「Sync New」同步工程。

6.2 本地预设账号数据(user_info.json)

文件路径:src/main/resources/rawfile/user_info.json

{
  "phone": "13800138000",
  "password": "Admin123456"
}

6.3 启动引导页(Index.ets)

// pages/Index.ets(MVVM-View层)
import { router } from '@kit.ArkUI';
import { Context } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

import AppStorageKey from '../constants/AppStorageKey';
import { LoginViewModel } from '../viewmodel/LoginViewModel';

@Entry
@Component
struct Index {
  // 响应式登录状态(关联AppStorage):View层仅读取状态,不修改业务逻辑
  @StorageLink(AppStorageKey.IS_LOGIN) isLogin: boolean = false;
  // 应用上下文(需导入Context类型)
  private context: Context = this.getUIContext().getHostContext() as Context;
  // 初始化LoginViewModel单例实例:View层仅调用方法,不参与逻辑实现
  private loginVM: LoginViewModel = LoginViewModel.getInstance();

  build() {
    // View层核心:仅渲染UI,无任何业务逻辑
    Column() {
      Text('这里是启动页面哦')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ bottom: 40 });
    }
    .width('100%')
    .height('100%')
    .backgroundColor(Color.White)
    .justifyContent(FlexAlign.Center);
  }

  /**
   * 页面加载时判断登录状态,自动跳转对应页面
   * View层职责:调用ViewModel方法获取状态,根据状态做UI层面的路由跳转
   */
  async aboutToAppear() {
    // 调用ViewModel方法:仅触发逻辑,不关心内部实现
    await this.loginVM.readLoginState(this.context);

    // 延迟执行,避免页面闪屏(纯UI体验优化,无业务逻辑)
    setTimeout(() => {
      if (this.isLogin) {
        // 已登录:跳转商品管理页(暂时使用router)
        router.replaceUrl({ url: 'pages/GoodsPage' }).catch((e: BusinessError) => {
          console.error('跳转商品页失败:', e.message);
        });
      } else {
        // 未登录:跳转登录页
        router.replaceUrl({ url: 'pages/LoginPage' }).catch((e: BusinessError) => {
          console.error('跳转登录页失败:', e.message);
        });
      }
    }, 1500);
  }
}

6.4 登录页(LoginPage.ets)

// pages/LoginPage.ets(MVVM-View层)
import { Account } from '../model/login/Account';
import { LoginViewModel } from '../viewmodel/LoginViewModel';
import { router } from '@kit.ArkUI';
import { Context } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct LoginPage {
  private context: Context = this.getUIContext().getHostContext() as Context;
  // View层仅维护UI相关状态,不维护业务状态
  @State phone: string = '';
  @State password: string = '';
  @State errorTip: string = '';
  // 仅持有ViewModel实例,调用方法
  private loginVM: LoginViewModel = LoginViewModel.getInstance();

  /**
   * 登录按钮点击事件:View层仅做事件转发,所有业务逻辑交给ViewModel
   */
  async onLoginClick() {
    this.errorTip = '';
    try {
      // 1. 收集UI输入数据(View层职责)
      const account: Account = { phone: this.phone, password: this.password };
      // 2. 调用ViewModel方法(不关心内部如何校验、存储)
      this.loginVM.updateAccount(account);
      const result = await this.loginVM.validateLogin(this.context);

      // 3. 根据ViewModel返回结果处理UI(仅展示提示、跳转页面)
      if (result.success) {
        await router.replaceUrl({ url: 'pages/GoodsPage' }).catch((e: BusinessError) => {
          this.errorTip = `页面跳转失败:${e.message}`;
        });
      } else {
        this.errorTip = result.message;
      }
    } catch (error) {
      this.errorTip = `登录异常:${(error as Error).message}`;
    }
  }

  // View层核心:仅渲染UI,绑定输入/点击事件,无任何业务判断
  build() {
    Column() {
      Text('商品管理系统')
        .fontSize(32)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ bottom: 60 });

      TextInput({ placeholder: '请输入手机号', text: this.phone })
        .type(InputType.PhoneNumber)
        .onChange((value) => this.phone = value)
        .width('100%')
        .height(48)
        .padding(10)
        .backgroundColor(Color.White)
        .border({ width: 1, color: '#e5e5e5', radius: 6 })
        .margin({ bottom: 20 });

      TextInput({ placeholder: '请输入密码(8-20位含数字、字母)', text: this.password })
        .type(InputType.Password)
        .onChange((value) => this.password = value)
        .width('100%')
        .height(48)
        .padding(10)
        .backgroundColor(Color.White)
        .border({ width: 1, color: '#e5e5e5', radius: 6 })
        .margin({ bottom: 30 });

      Text(this.errorTip)
        .fontSize(12)
        .fontColor(Color.Red)
        .width('100%')
        .textAlign(TextAlign.Start)
        .margin({ bottom: 10 });

      Button('登录')
        .onClick(() => this.onLoginClick())
        .width('100%')
        .height(48)
        .backgroundColor('#007dff')
        .fontColor(Color.White)
        .borderRadius(6)
        .fontSize(16);
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f5f5')
    .padding(30)
    .justifyContent(FlexAlign.Center);
  }
}

七、功能测试与问题排查

7.1 测试场景与预期结果

测试场景 预期结果
首次启动应用 启动页显示1.5秒后,自动跳转至登录页
未输入手机号点击登录 提示“请输入手机号”
输入12位数字(138001380001)点击登录 提示“手机号格式错误(11位数字,以13-9开头)”
输入正确手机号+错误密码(Admin1234567) 提示“手机号或密码错误”
输入正确账号(13800138000/Admin123456) 提示“登录成功”,跳转至商品管理页
未创建user_info.json 提示“登录失败:文件user_info.json不存在或格式错误”
登录成功后重启应用 启动页直接跳转至商品管理页(无需重新登录)
手动删除沙箱中user_account.json后重启 启动页跳转至登录页
输入手机号/密码含前后空格(如 13800138000 ViewModel自动清洗空格,正常校验不影响登录结果

7.2 常见问题排查

  1. 导入路径错误
    • 检查AppStorageKeyLoginViewModel等文件的相对导入路径(如../constants/AppStorageKey);
    • 使用DevEco Studio的“自动导入”功能(Alt+Enter)修正路径,确保路径层级与文件目录一致。
  2. Context使用错误
    • 确保导入import { Context } from '@kit.AbilityKit'
    • 预览器不支持Context和沙箱文件操作,需使用真机/模拟器测试。
  3. 路由跳转失败
    • 检查main_pages.json中页面路径与实际文件名一致(区分大小写);
    • 跳转URL需与配置完全匹配(如pages/GoodsPage而非pages/GoodsManagerPage);
    • 捕获BusinessError并打印错误信息,定位跳转失败原因。
  4. JSON文件读写失败
    • user_info.json需放在rawfile根目录,且JSON格式无语法错误(无多余逗号、引号匹配);
    • 沙箱读写失败需确认API版本(适配API12+),API12默认拥有files目录读写权限。
  5. AppStorage状态不生效
    • 确保@StorageLink绑定正确的常量键值(如AppStorageKey.IS_LOGIN);
    • 确保readLoginState方法在页面aboutToAppear生命周期中优先执行,保证状态先初始化再判断跳转。
  6. 单例ViewModel调用错误
    • 确保通过LoginViewModel.getInstance()获取实例,而非直接new LoginViewModel()(私有构造函数禁止外部实例化);
    • 避免在多个页面重复创建实例,统一通过getInstance()保证全局唯一。

当前阶段我们并不设计太多UI内容,所以并未对UI组件层做抽离。UI阶段我们会结合实际情况进行通用组件分离。

八、内容总结

  1. MVVM分层核心
    • Model层:仅定义数据结构(Account/ValidateResult),无任何业务逻辑,是整个架构的“数据载体”;
    • ViewModel层:单例模式封装所有业务逻辑(校验、存储、数据交互),作为View和Model的“中间桥梁”,不涉及UI;
    • View层:仅负责UI渲染和用户交互,通过调用ViewModel方法完成业务操作,不包含核心逻辑,实现“视图与逻辑完全解耦”;
  2. 登录流程闭环(MVVM落地体现):View层触发操作→ViewModel层处理逻辑→Model层承载数据→ViewModel同步状态→View层响应状态更新,形成完整的MVVM交互链路;
  3. 工程化规范:统一常量管理、工具类复用、目录结构设计,区分rawfile(静态预设数据)和沙箱(动态持久化数据)的使用场景,为MVVM架构提供工程化支撑;
  4. 核心技术整合:ArkTS异步编程、正则校验、Context、AppStorage等技术,均服务于MVVM层间的高效交互,而非独立存在。

九、代码仓库

十、下集预告

本节核心落地了MVVM的分层设计思想,完成了基于单例ViewModel的登录模块开发。下一节将继续围绕MVVM架构,聚焦hilog日志工具的工程化封装(ViewModel层的日志规范),同时整合单例模式实现商品数据的全局管理(扩展Model/ViewModel层能力),重构商品模块底层架构,让MVVM思想在复杂业务场景中落地,支持折扣、满减、秒杀等促销场景的扩展开发。

posted @ 2026-01-25 12:12  鸿蒙-散修  阅读(0)  评论(0)    收藏  举报