HarmonyOS 应用开发进阶案例(二):实现验证码登录

58 阅读12分钟

本案例基于窗口能力,实现验证码登录的场景,主要完成以下功能:

  1. 登录页面主窗口实现沉浸式。
  2. 输入用户名和密码后,拉起验证码校验子窗口。
  3. 验证码校验成功后,主窗口跳转到应用首页。

1. 案例效果截图

2. 案例运用到的知识点

  1. 核心知识点
  • 主窗口:应用主窗口用于显示应用界面,会在“任务管理界面”显示。
  • 子窗口:应用子窗口用于显示应用的弹窗、悬浮窗等辅助窗口。
  • 沉浸式:指对状态栏、导航栏等系统窗口进行控制,减少状态栏导航栏等系统界面的突兀感,从而使用户获得最佳体验的能力。
  1. 其他知识点
  • ArkTS 语言基础
  • V2版状态管理:@ComponentV2/@Local/@Param/@Monitor
  • 自定义组件和组件生命周期
  • @Builder装饰器:自定义构建函数
  • @Extend装饰器:定义扩展组件样式
  • ForEach:循环渲染
  • if/else:条件渲染
  • 内置组件:Column/Row/Scroll/Swiper/Grid/Stack/Text/TextInput/Image/Button/Blank/Line/Rect
  • 日志管理类的编写
  • 常量与资源分类的访问
  • 组件导航 (Navigation)
  • 页面路由 (@ohos.router)
  • MVVM模式

3. 代码结构

├──entry/src/main/ets               // 代码区 
│  ├──common 
│  │  ├──constants
│  │  │  └──CommonConstants.ets     // 公共常量类  
│  │  └──utils 
│  │     ├──GlobalContext.ets       // 全局上下文
│  │     └──Logger.ets              // 公共日志类    
│  ├──entryability 
│  │  └──EntryAbility.ets           // 程序入口类 
│  ├──model
│  │  └──WindowModel.ets            // 应用后端数据管理类
│  ├──pages 
│  │  ├──HomePage.ets               // 登录之后的首页
│  │  ├──LoginPage.ets              // 登录页面
│  │  ├──SuccessPage.ets            // 验证码校验成功页面
│  │  └──VerifyPage.ets             // 输入验证码页面
│  └──viewmodel
│     ├──GridItem.ets               // 首页网格数据实体类  
│     ├──WindowViewModel.ets        // 应用界面数据管理类
│     └──VerifyItem.ets             // 验证码数据实体类  
└──entry/src/main/resources         // 资源文件目录

4. 公共文件与资源

本案例涉及到的公共文件包括公共常量类、全局上下文类、日志类三个部分。

  1. 公共常量类(CommonConstants)
// main/ets/common/constants/CommonConstants.ets
export default class CommonConstants {
  static readonly INPUT_ACCOUNT_LENGTH: number = 11
  static readonly INPUT_PASSWORD_LENGTH: number = 8
  static readonly COMMON_SPACE: number = 12
  static readonly LOGIN_WAIT_TIME: number = 2000
  static readonly SUCCESS_PAGE_URL: string = 'pages/SuccessPage'
  static readonly HOME_PAGE_URL: string = 'pages/HomePage'
  static readonly LOGIN_PAGE_URL: string = 'pages/LoginPage'
  static readonly VERIFY_PAGE_URL: string = 'pages/VerifyPage'
  static readonly HOME_PAGE_ACTION: string = 'startHomePage'
  static readonly SUB_WINDOW_NAME: string = 'subWindow'
  static readonly STATUS_BAR_COLOR: string = '#F1F3F5'
  static readonly STATUS_BAR_CONTENT_COLOR: string = '#000000'
  static readonly SUB_WINDOW_WIDTH_RATIO: number = 0.93
  static readonly SUB_WINDOW_ASPECT_RATIO: number = 1.25
  static readonly FULL_PARENT: string = '100%'
  static readonly BUTTON_WIDTH: string = '86.7%'
  static readonly GRID_FOUR_COLUMNS: string = '1fr 1fr 1fr 1fr'
  static readonly GRID_TWO_ROWS: string = '1fr 1fr'
  static readonly GRID_TWO_COLUMNS: string = '1fr 1fr'
  static readonly GRID_THREE_ROWS: string = '1fr 1fr 1fr'
}

2. 全局上下文单例模式类(GlobalContext)

// main/ets/common/utils/GlobalContext.ets
export class GlobalContext {
  private constructor() {
  }

  private static instance: GlobalContext
  private _objects = new Map<string, Object>()

  public static getContext(): GlobalContext {
    if (!GlobalContext.instance) {
      GlobalContext.instance = new GlobalContext()
    }
    return GlobalContext.instance
  }

  getObject(value: string): Object | undefined {
    return this._objects.get(value)
  }

  setObject(key: string, objectClass: Object): void {
    this._objects.set(key, objectClass)
  }
}

3. 日志类(Logger)

import { hilog } from '@kit.PerformanceAnalysisKit'

export class Logger {
  private domain: number
  private prefix: string

  private format: string = '%{public}s, %{public}s'
  
  constructor(prefix: string = 'WindowManager', domain: number = 0xFF00) {
    this.prefix = prefix
    this.domain = domain
  }

  debug(...args: string[]): void {
    hilog.debug(this.domain, this.prefix, this.format, args)
  }

  info(...args: string[]): void {
    hilog.info(this.domain, this.prefix, this.format, args)
  }

  warn(...args: string[]): void {
    hilog.warn(this.domain, this.prefix, this.format, args)
  }

  error(...args: string[]): void {
    hilog.error(this.domain, this.prefix, this.format, args)
  }
}

export default new Logger()

本案例涉及到的资源文件如下:

  1. string.json
// main/resources/base/element/string.json
// main/resources/en_US/element/string.json
{
  "string": [
    {
      "name": "module_desc",
      "value": "module description"
    },
    {
      "name": "EntryAbility_desc",
      "value": "description"
    },
    {
      "name": "EntryAbility_label",
      "value": "WindowManager"
    },
    {
      "name": "login_page",
      "value": "Login page"
    },
    {
      "name": "login_more",
      "value": "Log in to your account to use more services"
    },
    {
      "name": "account",
      "value": "Account"
    },
    {
      "name": "password",
      "value": "Password"
    },
    {
      "name": "verify",
      "value": "Login via Verification Code"
    },
    {
      "name": "register_account",
      "value": "Registering an Account"
    },
    {
      "name": "forgot_password",
      "value": "Forgot Password"
    },
    {
      "name": "message_login",
      "value": "Login via SMS"
    },
    {
      "name": "change_one",
      "value": "Change One"
    },
    {
      "name": "verify_input_placeholder",
      "value": "Please enter the verification code"
    },
    {
      "name": "verify_wrong_hints",
      "value": "Incorrect verification code. Please try again"
    },
    {
      "name": "verify_hints",
      "value": "To protect your network security, please enter the verification code"
    },
    {
      "name": "verify_ok",
      "value": "OK"
    },
    {
      "name": "success",
      "value": "Verification success"
    },
    {
      "name": "success_login_hints",
      "value": "The login page is automatically displayed in 2 seconds"
    },
    {
      "name": "my_love",
      "value": "My favorite"
    },
    {
      "name": "history_record",
      "value": "History"
    },
    {
      "name": "message",
      "value": "Message"
    },
    {
      "name": "shopping_cart",
      "value": "Shopping cart"
    },
    {
      "name": "my_goal",
      "value": "My goal"
    },
    {
      "name": "group",
      "value": "Group"
    },
    {
      "name": "favorites",
      "value": "Favorites"
    },
    {
      "name": "recycle_bin",
      "value": "Recycle Bin"
    },
    {
      "name": "home_top",
      "value": "Rankings"
    },
    {
      "name": "home_text_top",
      "value": "XiaMen Station, we'll see you."
    },
    {
      "name": "home_new",
      "value": "New product launch"
    },
    {
      "name": "home_text_new",
      "value": "XiaMen Station, we'll see you."
    },
    {
      "name": "home_brand",
      "value": "Big-name flash shopping"
    },
    {
      "name": "home_text_brand",
      "value": "More Big Names"
    },
    {
      "name": "home_found",
      "value": "Discover the good stuff"
    },
    {
      "name": "home_text_found",
      "value": "XiaMen Station, we'll see you."
    },
    {
      "name": "home_list",
      "value": "List"
    },
    {
      "name": "home_title",
      "value": "Home"
    }
  ]
}
// main/resources/zh_CN/element/string.json
{
  "string": [
    {
      "name": "module_desc",
      "value": "模块描述"
    },
    {
      "name": "EntryAbility_desc",
      "value": "描述"
    },
    {
      "name": "EntryAbility_label",
      "value": "窗口管理"
    },
    {
      "name": "login_page",
      "value": "登录界面"
    },
    {
      "name": "login_more",
      "value": "登录帐号以使用更多服务"
    },
    {
      "name": "account",
      "value": "帐号"
    },
    {
      "name": "password",
      "value": "密码"
    },
    {
      "name": "verify",
      "value": "验证码检测"
    },
    {
      "name": "register_account",
      "value": "注册帐号"
    },
    {
      "name": "forgot_password",
      "value": "忘记密码"
    },
    {
      "name": "message_login",
      "value": "短信验证码登录"
    },
    {
      "name": "change_one",
      "value": "换一张"
    },
    {
      "name": "verify_input_placeholder",
      "value": "请输入验证码"
    },
    {
      "name": "verify_wrong_hints",
      "value": "验证码错误,请重新输入"
    },
    {
      "name": "verify_hints",
      "value": "为了保护你的网络安全,请输入验证码"
    },
    {
      "name": "verify_ok",
      "value": "确定"
    },
    {
      "name": "success",
      "value": "验证成功"
    },
    {
      "name": "success_login_hints",
      "value": "2s后自动跳转登录"
    },
    {
      "name": "my_love",
      "value": "我的最爱"
    },
    {
      "name": "history_record",
      "value": "历史记录"
    },
    {
      "name": "message",
      "value": "消息"
    },
    {
      "name": "shopping_cart",
      "value": "购物车"
    },
    {
      "name": "my_goal",
      "value": "我的目标"
    },
    {
      "name": "group",
      "value": "圈子"
    },
    {
      "name": "favorites",
      "value": "收藏"
    },
    {
      "name": "recycle_bin",
      "value": "回收站"
    },
    {
      "name": "home_top",
      "value": "排行榜"
    },
    {
      "name": "home_text_top",
      "value": "厦门站,我们不见不散"
    },
    {
      "name": "home_new",
      "value": "新品首发"
    },
    {
      "name": "home_text_new",
      "value": "厦门站,我们不见不散"
    },
    {
      "name": "home_brand",
      "value": "大牌闪购"
    },
    {
      "name": "home_text_brand",
      "value": "更多大牌"
    },
    {
      "name": "home_found",
      "value": "发现好物"
    },
    {
      "name": "home_text_found",
      "value": "厦门站,我们不见不散"
    },
    {
      "name": "home_list",
      "value": "列表"
    },
    {
      "name": "home_title",
      "value": "首页"
    }
  ]
}

2. float.json

// main/resources/base/element/float.json
{
  "float": [
    {
      "name": "logo_image_size",
      "value": "78vp"
    },
    {
      "name": "logo_margin_top",
      "value": "125vp"
    },
    {
      "name": "logo_margin_bottom",
      "value": "9vp"
    },
    {
      "name": "page_title_text_size",
      "value": "24fp"
    },
    {
      "name": "normal_text_size",
      "value": "16fp"
    },
    {
      "name": "large_text_size",
      "value": "21fp"
    },
    {
      "name": "big_text_size",
      "value": "16fp"
    },
    {
      "name": "small_text_size",
      "value": "14fp"
    },
    {
      "name": "little_text_size",
      "value": "12fp"
    },
    {
      "name": "login_more_margin_bottom",
      "value": "24vp"
    },
    {
      "name": "login_more_margin_top",
      "value": "8vp"
    },
    {
      "name": "login_input_height",
      "value": "48vp"
    },
    {
      "name": "forgot_margin_top",
      "value": "12vp"
    },
    {
      "name": "forgot_margin",
      "value": "12vp"
    },
    {
      "name": "input_padding",
      "value": "12vp"
    },
    {
      "name": "line_height",
      "value": "0.5vp"
    },
    {
      "name": "line_margin",
      "value": "12vp"
    },
    {
      "name": "login_button_height",
      "value": "40vp"
    },
    {
      "name": "login_button_margin_top",
      "value": "231vp"
    },
    {
      "name": "login_register_margin_bottom",
      "value": "40vp"
    },
    {
      "name": "login_button_margin_bottom",
      "value": "12vp"
    },
    {
      "name": "login_page_padding_bottom",
      "value": "24vp"
    },
    {
      "name": "login_background_opacity",
      "value": "0.2"
    },
    {
      "name": "login_padding",
      "value": "12vp"
    },
    {
      "name": "verify_padding",
      "value": "24vp"
    },
    {
      "name": "verify_image_height",
      "value": "48vp"
    },
    {
      "name": "verify_image_width",
      "value": "164vp"
    },
    {
      "name": "verify_hints_width",
      "value": "88vp"
    },
    {
      "name": "verify_hints_padding",
      "value": "40vp"
    },
    {
      "name": "verify_text_input_margin",
      "value": "8vp"
    },
    {
      "name": "verify_ok_margin",
      "value": "32vp"
    },
    {
      "name": "verify_bottom_padding",
      "value": "12vp"
    },
    {
      "name": "verify_auto_jump_bottom_margin",
      "value": "24vp"
    },
    {
      "name": "success_image_size",
      "value": "56vp"
    },
    {
      "name": "success_image_margin",
      "value": "32vp"
    },
    {
      "name": "home_padding",
      "value": "12vp"
    },
    {
      "name": "home_tab_titles_margin",
      "value": "12vp"
    },
    {
      "name": "home_tab_titles_padding",
      "value": "12vp"
    },
    {
      "name": "home_swiper_border_radius",
      "value": "16vp"
    },
    {
      "name": "home_swiper_margin",
      "value": "24vp"
    },
    {
      "name": "home_grid_columns_gap",
      "value": "8vp"
    },
    {
      "name": "home_grid_row_gap",
      "value": "12vp"
    },
    {
      "name": "home_grid_padding",
      "value": "12vp"
    },
    {
      "name": "home_grid_height",
      "value": "124vp"
    },
    {
      "name": "background_text_margin",
      "value": "4vp"
    },
    {
      "name": "home_list_padding",
      "value": "8vp"
    },
    {
      "name": "background_border_radius",
      "value": "24vp"
    },
    {
      "name": "home_text_margin",
      "value": "12vp"
    },
    {
      "name": "home_background_border_radius",
      "value": "12vp"
    },
    {
      "name": "home_second_grid_height",
      "value": "406vp"
    },
    {
      "name": "home_home_cell_size",
      "value": "24vp"
    },
    {
      "name": "home_home_cell_margin",
      "value": "4vp"
    },
    {
      "name": "home_button_bottom",
      "value": "55vp"
    }
  ]
}

3. color.json

// main/resources/base/element/color.json
{
  "color": [
    {
      "name": "start_window_background",
      "value": "#FFFFFF"
    },
    {
      "name": "background",
      "value": "#F1F3F5"
    },
    {
      "name": "title_text_color",
      "value": "#182431"
    },
    {
      "name": "login_more_text_color",
      "value": "#99182431"
    },
    {
      "name": "placeholder_color",
      "value": "#99182431"
    },
    {
      "name": "line_color",
      "value": "#0D000000"
    },
    {
      "name": "login_button_color",
      "value": "#007DFF"
    },
    {
      "name": "login_button_disable",
      "value": "#66007DFF"
    },
    {
      "name": "login_font_disable",
      "value": "#FFFFFF"
    },
    {
      "name": "login_blue_text_color",
      "value": "#007DFF"
    },
    {
      "name": "verify_wrong_hints_color",
      "value": "#FFFA2A2D"
    },
    {
      "name": "home_grid_font_color",
      "value": "#99182431"
    }
  ]
}

其他资源请到源码中获取。

5. 页面沉浸式设置

5.1. EntryAbility

// main/ets/entryability/EntryAbility.ets
import { UIAbility } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'
import CommonConstants from '../common/constants/CommonConstants'
import Logger from '../common/utils/Logger'
import WindowModel from '../model/WindowModel'

// 定义EntryAbility类,继承自UIAbility
export default class EntryAbility extends UIAbility {
  // 当窗口阶段创建时调用
  onWindowStageCreate(windowStage: window.WindowStage) {
    Logger.info('Ability onWindowStageCreate')

    // 获取WindowModel的单例实例
    let windowModel = WindowModel.getInstance()
    // 设置当前窗口阶段
    windowModel.setWindowStage(windowStage)
    // 设置主窗口为沉浸式(全屏模式)
    windowModel.setMainWindowImmersive()

    // 加载指定页面(LoginPage)的内容
    windowStage.loadContent(CommonConstants.LOGIN_PAGE_URL, (err) => {
      if (err.code) {
        Logger.error(`Failed to load the content. Code:${err.code},message:${err.message}`)
        return
      }
    })
  }
}

关键代码说明:

  • 在EntryAbility里的onWindowStageCreate生命周期函数里,获取WindowModel的单例实例。
  • 设置当前窗口阶段。
  • 设置主窗口为沉浸式(全屏模式)。
  • 加载LoginPage页面。

5.2. WindowModel

// main/ets/model/WindowModel.ets
import { window, display } from '@kit.ArkUI'  // 导入ArkUI的window和display模块,用于窗口管理和屏幕信息获取
import CommonConstants from '../common/constants/CommonConstants'  // 导入公共常量
import Logger from '../common/utils/Logger'  // 导入日志工具
import { GlobalContext } from '../common/utils/GlobalContext'  // 导入全局上下文工具
import { BusinessError } from '@kit.BasicServicesKit'  // 导入业务错误类型

// 定义WindowModel类,用于管理窗口的创建、配置和销毁
export default class WindowModel {
  private windowStage?: window.WindowStage  // 当前窗口阶段实例
  private subWindowClass?: window.Window  // 子窗口实例

  // 获取WindowModel的单例实例
  static getInstance(): WindowModel {
    let instance: WindowModel = GlobalContext.getContext().getObject('windowModel') as WindowModel
    if (instance === undefined) {  // 如果实例不存在,则创建一个新的实例
      instance = new WindowModel()
      GlobalContext.getContext().setObject('windowModel', instance)  // 将实例存储到全局上下文中
    }
    return instance
  }

  // 设置当前窗口阶段
  setWindowStage(windowStage: window.WindowStage) {
    this.windowStage = windowStage
  }

  // 创建子窗口
  // ...

  // 设置主窗口为沉浸式模式
  setMainWindowImmersive() {
    if (this.windowStage === undefined) {  // 如果窗口阶段未定义,记录错误日志并返回
      Logger.error('windowStage is undefined.')
      return
    }

    // 获取主窗口实例
    this.windowStage.getMainWindow((err, windowClass: window.Window) => {
      if (err.code) {  // 如果获取失败,记录错误日志并返回
        Logger.error(`Failed to obtain the main window. Code:${err.code}, message:${err.message}`)
        return
      }

      let isLayoutFullScreen = true
      try {
        // 设置窗口布局为全屏模式
        let promise = windowClass.setWindowLayoutFullScreen(isLayoutFullScreen)
        promise.then(() => {
          Logger.info('Succeeded in setting the window layout to full-screen mode.')
        }).catch((err: BusinessError) => {  // 如果设置失败,记录错误日志
          Logger.error(`Failed to set the window layout to full-screen mode. Cause code: ${err.code}, message: ${err.message}`)
        })
      } catch (exception) {  // 如果捕获到异常,记录错误日志
        Logger.error(`Failed to set the window layout to full-screen mode. Cause code: ${exception.code}, message: ${exception.message}`)
      }

      let names: Array<'status' | 'navigation'> = []
      try {
        // 设置系统栏(状态栏和导航栏)为不可见
        let promise = windowClass.setWindowSystemBarEnable(names)
        promise.then(() => {
          Logger.info('Succeeded in setting the system bar to be invisible.')
        }).catch((err: BusinessError) => {  // 如果设置失败,记录错误日志
          Logger.error(`Failed to set the system bar to be invisible. Cause code: ${err.code}, message: ${err.message}`)
        })
      } catch (exception) {  // 如果捕获到异常,记录错误日志
        Logger.error(`Failed to set the system bar to be invisible. Cause code: ${exception.code}, message: ${exception.message}`)
      }
    })
  }

  // 销毁子窗口
  // ...
}

关键代码说明:

  • 本部分代码只实现了EntryAbility需要的功能。
  • 实现了定义WindowModel类。
  • 实现了获取WindowModel的单例实例的getInstance方法。
  • 实现了设置当前窗口阶段setWindowStage方法。
  • 实现了设置主窗口为沉浸式模式setMainWindowImmersive方法。

6. 登录页面

6.1. LoginPage

// main/ets/pages/LoginPage.ets
import router from '@ohos.router'
import CommonConstants from '../common/constants/CommonConstants'
import Logger from '../common/utils/Logger'
import WindowModel from '../model/WindowModel'

// 定义一个扩展函数,用于设置TextInput的样式
@Extend(TextInput)
function inputStyle() {
  .placeholderColor($r('app.color.placeholder_color'))
  .backgroundColor($r('app.color.start_window_background'))
  .height($r('app.float.login_input_height'))
  .fontSize($r('app.float.big_text_size'))
  .padding({
    left: $r('app.float.input_padding'),
    right: $r('app.float.input_padding')
  })
}

// 定义一个扩展函数,用于设置Text的样式
@Extend(Text)
function blueTextStyle() {
  .fontColor($r('app.color.login_blue_text_color'))
  .fontSize($r('app.float.small_text_size'))
  .fontWeight(FontWeight.Medium)
  .margin({
    left: $r('app.float.forgot_margin'),
    right: $r('app.float.forgot_margin')
  })
}

// 定义登录页面组件
@Entry
@ComponentV2
struct LoginPage {
  @Local account: string = ''  // 本地状态,存储账号
  @Local password: string = ''  // 本地状态,存储密码
  @Local isShadow: boolean = false  // 本地状态,控制阴影显示
  private windowModel: WindowModel = WindowModel.getInstance()  // 获取窗口模型实例
  @Local isHome: boolean = false  // 本地状态,控制是否跳转到主页

  // 生命周期函数,页面即将显示时调用
  aboutToAppear() {
    // 监听HOME_PAGE_ACTION事件,当事件触发时设置isHome为true
    getContext(this).eventHub.on(CommonConstants.HOME_PAGE_ACTION, () => {
      this.isHome = true
    })
  }

  // 构建UI
  build() {
    Stack({ alignContent: Alignment.Top }) {
      Column() {
        Image($r('app.media.ic_logo'))
          .width($r('app.float.logo_image_size'))
          .height($r('app.float.logo_image_size'))
          .margin({
            top: $r('app.float.logo_margin_top'),
            bottom: $r('app.float.logo_margin_bottom')
          })
        Text($r('app.string.login_page'))
          .fontSize($r('app.float.page_title_text_size'))
          .fontWeight(FontWeight.Medium)
          .fontColor($r('app.color.title_text_color'))
        Text($r('app.string.login_more'))
          .fontSize($r('app.float.normal_text_size'))
          .fontColor($r('app.color.login_more_text_color'))
          .margin({
            bottom: $r('app.float.login_more_margin_bottom'),
            top: $r('app.float.login_more_margin_top')
          })

        Column() {
          TextInput({ placeholder: $r('app.string.account') })  // 账号输入框
            .maxLength(CommonConstants.INPUT_ACCOUNT_LENGTH)
            .inputStyle()
            .onChange((value: string) => {  // 监听输入变化
              this.account = value  // 更新账号状态
            })
          Line()  // 显示分割线
            .width(CommonConstants.FULL_PARENT)
            .height($r('app.float.line_height'))
            .backgroundColor($r('app.color.line_color'))
            .margin({
              left: $r('app.float.line_margin'),
              right: $r('app.float.line_margin')
            })
          TextInput({ placeholder: $r('app.string.password') })  // 密码输入框
            .maxLength(CommonConstants.INPUT_PASSWORD_LENGTH)
            .type(InputType.Password)
            .inputStyle()
            .onChange((value: string) => {  // 监听输入变化
              this.password = value  // 更新密码状态
            })
        }
        .padding({
          top: $r('app.float.background_text_margin'),
          bottom: $r('app.float.background_text_margin')
        })
        .width(CommonConstants.FULL_PARENT)
        .backgroundColor($r('app.color.start_window_background'))
        .borderRadius($r('app.float.background_border_radius'))

        Row() {
          Text($r('app.string.message_login')).blueTextStyle()
          Text($r('app.string.forgot_password')).blueTextStyle()
        }
        .justifyContent(FlexAlign.SpaceBetween)
        .width(CommonConstants.FULL_PARENT)
        .margin({ top: $r('app.float.forgot_margin_top') })

        Blank()  // 空白占位符

        Button($r('app.string.verify'), { type: ButtonType.Capsule })  // 显示验证按钮
          .width(CommonConstants.BUTTON_WIDTH)
          .height($r('app.float.login_button_height'))
          .fontSize($r('app.float.normal_text_size'))
          .fontWeight(FontWeight.Medium)
          .enabled(isLoginClickable(this.account, this.password))
          .backgroundColor(isLoginClickable(this.account, this.password) ? $r('app.color.login_button_color') :
          $r('app.color.login_button_disable'))
          .fontColor(isLoginClickable(this.account, this.password) ? Color.White : $r('app.color.login_font_disable'))
          .margin({
            top: $r('app.float.login_button_margin_top'),
            bottom: $r('app.float.login_button_margin_bottom')
          })
          .onClick(() => {  // 监听按钮点击事件
            this.windowModel.createSubWindow()  // 创建子窗口
            this.isShadow = true  // 显示阴影
          })
        Text($r('app.string.register_account'))
          .fontColor($r('app.color.login_blue_text_color'))
          .fontSize($r('app.float.normal_text_size'))
          .fontWeight(FontWeight.Medium)
          .margin({ bottom: $r('app.float.login_register_margin_bottom') })
      }
      .backgroundColor($r('app.color.background'))
      .height(CommonConstants.FULL_PARENT)
      .width(CommonConstants.FULL_PARENT)
      .padding({
        left: $r('app.float.login_padding'),
        right: $r('app.float.login_padding'),
        bottom: $r('app.float.login_page_padding_bottom')
      })

      if (this.isShadow) {  // 如果isShadow为true,显示阴影
        Rect()
          .width(CommonConstants.FULL_PARENT)
          .height(CommonConstants.FULL_PARENT)
          .fillOpacity($r('app.float.login_background_opacity'))
          .fill($r('app.color.title_text_color'))
      }
    }
  }

  // 监听isHome状态的变化
  @Monitor('isHome')
  toHome(monitor: IMonitor) {
    if (this.isHome) {  // 如果isHome为true,跳转到主页
      router.replaceUrl({
        url: CommonConstants.HOME_PAGE_URL
      }).catch((err: Error) => {
        Logger.error(`pushUrl failed, message:${err.message}`)
      })
    }
  }
}

// 判断登录按钮是否可点击
function isLoginClickable(account: string, password: string): boolean {
  return account !== '' && password !== ''  // 当账号和密码都不为空时返回true
}

关键代码说明:

  • 生命周期函数aboutToAppear,注册监听了HOME_PAGE_ACTION事件,当事件触发时设置isHome为true。
  • this.windowModel.createSubWindow(),创建了子窗口,用于显示验证页面和验证成功页面。
  • Rect()在isShadow为true时,显示一个模态窗口的蒙版。
  • @Monitor('isHome'),用于监听isHome状态的变化,如果isHome为true,则跳转到HomePage页面。

6.2. EntryAbility

import { window, display } from '@kit.ArkUI'
import CommonConstants from '../common/constants/CommonConstants'
import Logger from '../common/utils/Logger'
import { GlobalContext } from '../common/utils/GlobalContext'
import { BusinessError } from '@kit.BasicServicesKit'

// 定义WindowModel类,用于管理窗口的创建、配置和销毁
export default class WindowModel {
  private windowStage?: window.WindowStage  // 当前窗口阶段实例
  private subWindowClass?: window.Window  // 子窗口实例

  // 获取WindowModel的单例实例
  // ...

  // 设置当前窗口阶段
  // ...

  // 创建子窗口
  createSubWindow() {
    if (this.windowStage === undefined) {  // 如果窗口阶段未定义,记录错误日志并返回
      Logger.error('Failed to create the subWindow.')
      return
    }

    // 创建子窗口
    this.windowStage.createSubWindow(CommonConstants.SUB_WINDOW_NAME, (err, data: window.Window) => {
      if (err.code) {  // 如果创建失败,记录错误日志并返回
        Logger.error(`Failed to create the window. Code:${err.code}, message:${err.message}`)
        return
      }
      // 获取子窗口实例
      this.subWindowClass = data
      // 获取屏幕的宽度和高度
      let screenWidth = display.getDefaultDisplaySync().width
      let screenHeight = display.getDefaultDisplaySync().height
      // 根据子窗口的宽高比计算子窗口的宽度和高度
      let windowWidth = screenWidth * CommonConstants.SUB_WINDOW_WIDTH_RATIO
      let windowHeight = windowWidth / CommonConstants.SUB_WINDOW_ASPECT_RATIO
      // 计算子窗口的起始坐标
      let moveX = (screenWidth - windowWidth) / 2
      let moveY = screenHeight - windowHeight
      // 移动子窗口到指定位置
      this.subWindowClass.moveWindowTo(moveX, moveY, (err) => {
        if (err.code) {  // 如果移动失败,记录错误日志并返回
          Logger.error(`Failed to move the window. Code:${err.code}, message:${err.message}`)
          return
        }
      })
      // 调整子窗口的大小
      this.subWindowClass.resize(windowWidth, windowHeight, (err) => {
        if (err.code) {  // 如果调整大小失败,记录错误日志并返回
          Logger.error(`Failed to change the window size. Code:${err.code}, message:${err.message}`)
          return
        }
      })
      // 设置子窗口的内容
      this.subWindowClass.setUIContent(CommonConstants.VERIFY_PAGE_URL, (err) => {
        if (err.code) {  // 如果设置内容失败,记录错误日志并返回
          Logger.error(`Failed to load the content. Code:${err.code}, message:${err.message}`)
          return
        }
        if (this.subWindowClass === undefined) {  // 如果子窗口实例未定义,记录错误日志并返回
          Logger.error('subWindowClass is undefined.')
          return
        }
        // 设置子窗口的背景颜色为透明
        this.subWindowClass.setWindowBackgroundColor('#00000000')
        // 显示子窗口
        this.subWindowClass.showWindow((err) => {
          if (err.code) {  // 如果显示失败,记录错误日志并返回
            Logger.error(`Failed to show the window. Code:${err.code}, essage:${err.message}`)
            return
          }
        })
      })
    })
  }

  // 设置主窗口为沉浸式模式
  // ...

  // 销毁子窗口
  // ...
}

关键代码说明:

  • createSubWindow方法实现了创建子窗口。
  • setUIContent设置了子窗口的内容为VerifyPage页面。

7. 校验子窗口页面

7.1. 校验页面

// main/ets/pages/VerifyPage.ets
import { router } from '@kit.ArkUI'
import CommonConstants from '../common/constants/CommonConstants'
import Logger from '../common/utils/Logger'
import VerifyItem from '../viewmodel/VerifyItem'  // 导入验证项数据模型
import WindowViewModel from '../viewmodel/WindowViewModel'  // 导入窗口视图模型

// 定义一个扩展函数,用于设置Text的样式
@Extend(Text)
function promptTextStyle() {
  .fontSize($r('app.float.small_text_size'))
  .width(CommonConstants.FULL_PARENT)
  .padding({
    left: $r('app.float.verify_padding'),
    right: $r('app.float.verify_padding')
  })
}

// 定义验证页面组件
@Entry
@ComponentV2
struct VerifyPage {
  @Local isInputWrong: boolean = false
  @Local verifyItem: VerifyItem = new VerifyItem($r('app.media.ic_verity_character1'), 'XYZK')
  @Local inputText: string = ''
  private verifyMap: Map<number, VerifyItem> = new Map()
  private imageId: number = 0

  // 生命周期函数,页面即将显示时调用
  aboutToAppear() {
    // 从WindowViewModel获取验证项映射表
    this.verifyMap = WindowViewModel.getVerifyMap()
    // 更新当前验证项
    this.updateVerifyItem()
  }

  // 更新当前验证项
  updateVerifyItem(){
    // 根据imageId获取新的验证项
    let verifyItemNew: VerifyItem | undefined = this.verifyMap.get(this.imageId)
    if (verifyItemNew !== undefined) {
      // 更新当前验证项
      this.verifyItem = verifyItemNew
    }
  }

  build() {
    Column() {
      Column() {
        Row() {
          Image(this.verifyItem.image)  // 显示验证图片
            .height($r('app.float.verify_image_height'))
            .width($r('app.float.verify_image_width'))
          Text($r('app.string.change_one'))
            .fontColor($r('app.color.login_blue_text_color'))
            .fontSize($r('app.float.big_text_size'))
            .width($r('app.float.verify_hints_width'))
            .textAlign(TextAlign.Center)
            .onClick(() => {  // 监听点击事件
              this.imageId = (this.imageId + 1) % 2  // 切换验证项ID
              this.updateVerifyItem()  // 更新验证项
            })
        }
        .padding({
          left: $r('app.float.verify_padding'),
          top: $r('app.float.verify_padding'),
          right: $r('app.float.verify_hints_padding')
        })
        .width(CommonConstants.FULL_PARENT)
        .justifyContent(FlexAlign.SpaceBetween)

        // 显示输入框
        TextInput({ placeholder: $r('app.string.verify_input_placeholder'), text: this.inputText })
          .placeholderColor($r('app.color.placeholder_color'))
          .fontSize($r('app.float.big_text_size'))
          .height($r('app.float.login_button_height'))
          .margin({
            left: $r('app.float.verify_padding'),
            right: $r('app.float.verify_padding'),
            top: $r('app.float.verify_text_input_margin'),
            bottom: $r('app.float.verify_text_input_margin')
          })
          .onChange((value: string) => {
            this.inputText = value
          })
        if (this.isInputWrong) {  // 如果输入错误
          Text($r('app.string.verify_wrong_hints'))
            .fontColor($r('app.color.verify_wrong_hints_color'))
            .promptTextStyle()
        } else {  // 如果输入正确
          Text($r('app.string.verify_hints'))
            .fontColor($r('app.color.placeholder_color'))
            .promptTextStyle()
        }
        Text($r('app.string.verify_ok'))  // 显示“确定”按钮
          .fontSize($r('app.float.big_text_size'))
          .fontColor($r('app.color.login_blue_text_color'))
          .margin({
            top: $r('app.float.verify_ok_margin'),
            bottom: $r('app.float.verify_ok_margin')
          })
          .onClick(() => {  // 监听点击事件
            let verifyText: string = this.verifyItem.characters
            // 获取验证项的正确字符
            if (this.inputText.toLowerCase() === verifyText.toLowerCase()) { // 如果输入正确
              router.replaceUrl({  // 跳转到成功页面
                url: CommonConstants.SUCCESS_PAGE_URL
              }).catch((err: Error) => {  // 如果跳转失败,记录错误日志
                Logger.error(`pushUrl failed, message:${err.message}`)
              })
            } else {  // 如果输入错误
              this.isInputWrong = true  // 设置输入错误状态为true
              this.inputText = ''  // 清空输入内容
              this.imageId = (this.imageId + 1) % 2  // 切换验证项ID
              this.updateVerifyItem()  // 更新验证项
            }
          })
      }
      .backgroundColor(Color.White)
      .height(CommonConstants.FULL_PARENT)
      .borderRadius($r('app.float.verify_ok_margin'))
    }
    .padding({ bottom: $r('app.float.verify_bottom_padding') })
    .height(CommonConstants.FULL_PARENT)
  }
}

关键代码说明:

  • verifyMap: Map<number, VerifyItem>,定义验证Map。
  • 在aboutToAppear里从WindowViewModel获取验证项映射表WindowViewModel.getVerifyMap()。
  • updateVerifyItem函数更新验证,
  • this.inputText.toLowerCase() === verifyText.toLowerCase() 获取验证项的正确字符,跳转到成功页面SuccessPage。

7.2. 验证项数据模型

export default class VerifyItem {
  image: Resource
  characters: string
  constructor(image: Resource, characters: string) {
    this.image = image
    this.characters = characters
  }
}

7.3. 窗口视图模型

// main/ets/viewmodel/WindowViewModel.ets
// ...
import VerifyItem from './VerifyItem'

export class WindowViewModel {
  getVerifyMap(): Map<number, VerifyItem> {
    let verifyMap: Map<number, VerifyItem> = new Map()
    verifyMap.set(0, new VerifyItem($r('app.media.ic_verity_character1'), 'XYZK'))
    verifyMap.set(1, new VerifyItem($r('app.media.ic_verity_character2'), 'LHBR'))
    return verifyMap
  }
  
  // ...
}

export default new WindowViewModel()

8. 校验成功页面

// main/ets/pages/SuccessPage.ets
import CommonConstants from '../common/constants/CommonConstants'
import WindowModel from '../model/WindowModel'

@Entry
@ComponentV2
struct SuccessPage {
  // 生命周期函数,页面即将显示时调用
  aboutToAppear() {
    // 设置一个定时器,延迟执行以下操作
    setTimeout(() => {
      // 销毁子窗口
      WindowModel.getInstance().destroySubWindow() 
      // 触发HOME_PAGE_ACTION事件
      getContext(this).eventHub.emit(CommonConstants.HOME_PAGE_ACTION)  
    }, CommonConstants.LOGIN_WAIT_TIME)
  }

  build() {
    Column() { 
      Column() {
        Text($r('app.string.success'))  // 显示“成功”文本
          .fontSize($r('app.float.large_text_size'))
          .padding({
            left: $r('app.float.verify_padding'), 
            top: $r('app.float.verify_padding')
          })
          .width(CommonConstants.FULL_PARENT)
        Image($r('app.media.ic_success'))  // 显示成功图片
          .width($r('app.float.success_image_size')) 
          .height($r('app.float.success_image_size'))
          .margin({
            top: $r('app.float.success_image_margin'),
            bottom: $r('app.float.success_image_margin')
          })
        Text($r('app.string.success_login_hints'))  // 显示成功登录提示
          .fontSize($r('app.float.small_text_size'))
          .fontColor($r('app.color.placeholder_color'))
          .margin({
            bottom: $r('app.float.verify_auto_jump_bottom_margin')
          })
      }
      .backgroundColor(Color.White)
      .height(CommonConstants.FULL_PARENT)
      .borderRadius($r('app.float.verify_ok_margin'))
    }
    .padding({ bottom: $r('app.float.verify_bottom_padding') })
    .height(CommonConstants.FULL_PARENT)
  }
}

关键代码说明:

  • 在aboutToAppear里,两秒后自动销毁子窗口并触发打开首页面(HomePage)的事件。
  • WindowModel.getInstance().destroySubWindow() 销毁子窗口。
  • getContext(this).eventHub.emit(CommonConstants.HOME_PAGE_ACTION),触发HOME_PAGE_ACTION事件。

9. 首页面

9.1. HomePage

// main/ets/pages/HomePage.ets
import CommonConstants from '../common/constants/CommonConstants'
import ItemData from '../viewmodel/GridItem'
import WindowViewModel from '../viewmodel/WindowViewModel'

@Entry
@ComponentV2
struct HomePage {
  private swiperController: SwiperController = new SwiperController()

  // 定义导航栏标题的UI构建函数
  @Builder
  NavigationTitle() {
    Text($r('app.string.home_title'))
      .fontWeight(FontWeight.Medium)
      .fontSize($r('app.float.page_title_text_size'))
      .margin({ top: $r('app.float.home_tab_titles_margin') })
      .padding({ left: $r('app.float.home_tab_titles_padding') })
  }

  build() {
    Navigation() {  // 使用Navigation组件作为页面容器
      Scroll() {  // 使用Scroll组件实现页面滚动
        Column({ space: CommonConstants.COMMON_SPACE }) {
          // 轮播图部分
          Swiper(this.swiperController) {
            ForEach(WindowViewModel.getSwiperImages(), (img: Resource, index?: number) => {
              Image(img).borderRadius($r('app.float.home_swiper_border_radius'))
            }, (img: Resource, index?: number) => index + JSON.stringify(img.id))
          }
          .margin({
            top: $r('app.float.home_swiper_margin')
          })
          .autoPlay(true)

          // 第一个Grid部分
          Grid() {
            ForEach(WindowViewModel.getFirstGridData(), (firstItem: ItemData, index?: number) => {
              GridItem() {
                Column() {
                  Image(firstItem.image)
                    .width($r('app.float.home_home_cell_size'))
                    .height($r('app.float.home_home_cell_size'))
                  Text(firstItem.title)
                    .fontSize($r('app.float.little_text_size'))
                    .margin({ top: $r('app.float.home_home_cell_margin') })
                }
              }
            }, (firstItem: ItemData, index?: number) => index + JSON.stringify(firstItem))
          }
          .columnsTemplate(CommonConstants.GRID_FOUR_COLUMNS)
          .rowsTemplate(CommonConstants.GRID_TWO_ROWS)
          .columnsGap($r('app.float.home_grid_columns_gap'))
          .rowsGap($r('app.float.home_grid_row_gap'))
          .padding({
            top: $r('app.float.home_grid_padding'),
            bottom: $r('app.float.home_grid_padding')
          })
          .height($r('app.float.home_grid_height'))
          .backgroundColor(Color.White)
          .borderRadius($r('app.float.background_border_radius'))

          // 列表标题
          Text($r('app.string.home_list'))
            .fontSize($r('app.float.normal_text_size'))
            .fontWeight(FontWeight.Medium)
            .width(CommonConstants.FULL_PARENT)
            .margin({ top: $r('app.float.home_text_margin') })

          // 第二个Grid部分
          Grid() {
            ForEach(WindowViewModel.getSecondGridData(), (secondItem: ItemData, index?: number) => {
              GridItem() {
                Column() {
                  Text(secondItem.title)  // 显示标题
                    .fontSize($r('app.float.normal_text_size'))
                    .fontWeight(FontWeight.Medium)
                  Text(secondItem.others)
                    .margin({ top: $r('app.float.background_text_margin') })
                    .fontSize($r('app.float.little_text_size'))
                    .fontColor($r('app.color.home_grid_font_color'))
                }
                .alignItems(HorizontalAlign.Start)
              }
              .padding({
                top: $r('app.float.home_list_padding'),
                left: $r('app.float.home_list_padding')
              })
              .borderRadius($r('app.float.home_background_border_radius'))
              .align(Alignment.TopStart)
              .backgroundImage(secondItem.image)
              .backgroundImageSize(ImageSize.Cover)
              .width(CommonConstants.FULL_PARENT)
              .height(CommonConstants.FULL_PARENT)
            }, (secondItem: ItemData, index?: number) => index + JSON.stringify(secondItem))
          }
          .width(CommonConstants.FULL_PARENT)
          .height($r('app.float.home_second_grid_height'))
          .columnsTemplate(CommonConstants.GRID_TWO_COLUMNS)
          .rowsTemplate(CommonConstants.GRID_THREE_ROWS)
          .columnsGap($r('app.float.home_grid_columns_gap'))
          .rowsGap($r('app.float.home_grid_row_gap'))
          .margin({ bottom: $r('app.float.home_button_bottom') })
        }
      }
      .scrollBar(BarState.Off)
      .margin({
        left: $r('app.float.home_padding'),
        right: $r('app.float.home_padding')
      })
    }
    .title(this.NavigationTitle())  // 设置导航栏标题
    .titleMode(NavigationTitleMode.Mini)  // 设置导航栏标题模式为Mini
    .hideBackButton(true)  // 隐藏返回按钮
    .backgroundColor($r('app.color.background'))  // 设置背景颜色
    .mode(NavigationMode.Stack)  // 设置导航模式为Stack
  }
}

9.2. 构建首页面的VM

// main/ets/viewmodel/WindowViewModel.ets
import GridItem from './GridItem'
// ...

export class WindowViewModel {
  // ...
  
  getSwiperImages(): Array<Resource> {
    let swiperImages: Resource[] = [
      $r('app.media.ic_swiper1'),
      $r('app.media.ic_swiper2'),
      $r('app.media.ic_swiper3'),
      $r('app.media.ic_swiper4')
    ]
    return swiperImages
  }
  
  getFirstGridData(): Array<GridItem> {
    let firstGridData: GridItem[] = [
      new GridItem($r('app.string.my_love'), $r('app.media.ic_love')),
      new GridItem($r('app.string.history_record'), $r('app.media.ic_record')),
      new GridItem($r('app.string.message'), $r('app.media.ic_message')),
      new GridItem($r('app.string.shopping_cart'), $r('app.media.ic_shopping')),
      new GridItem($r('app.string.my_goal'), $r('app.media.ic_target')),
      new GridItem($r('app.string.group'), $r('app.media.ic_circle')),
      new GridItem($r('app.string.favorites'), $r('app.media.ic_favorite')),
      new GridItem($r('app.string.recycle_bin'), $r('app.media.ic_recycle'))
    ]
    return firstGridData
  }
  
  getSecondGridData(): Array<GridItem> {
    let secondGridData: GridItem[] = [
      new GridItem($r('app.string.home_top'), $r('app.media.ic_top'), $r('app.string.home_text_top')),
      new GridItem($r('app.string.home_new'), $r('app.media.ic_new'), $r('app.string.home_text_new')),
      new GridItem($r('app.string.home_brand'), $r('app.media.ic_brand'), $r('app.string.home_text_brand')),
      new GridItem($r('app.string.home_found'), $r('app.media.ic_found'), $r('app.string.home_text_found')),
      new GridItem($r('app.string.home_top'), $r('app.media.ic_top'), $r('app.string.home_text_top')),
      new GridItem($r('app.string.home_new'), $r('app.media.ic_new'), $r('app.string.home_text_new'))
    ]
    return secondGridData
  }
}

export default new WindowViewModel()

✋ 需要参加鸿蒙认证的请点击 鸿蒙认证链接