第 8 章 通知与窗口(下)

145 阅读10分钟

8.3 案例实战

8.3.1 实现进度条通知

本案例主要介绍如何使用通知能力和基础组件,实现模拟下载文件,发送通知。

8.3.1.1 案例效果截图

8.3.1.2 案例运用到的知识点

  1. 核心知识点
  • 通知:可以通过通知接口发送消息,终端用户可以通过通知栏查看通知内容,也可以点击通知来打开应用。
  1. 其他知识点
  • ArkTS 语言基础
  • V2版状态管理:@ComponentV2/@Local/
  • Stage模型
  • 自定义组件和组件生命周期
  • @Builder装饰器:自定义构建函数
  • @Extend装饰器:定义扩展组件样式
  • if/else:条件渲染
  • 内置组件:Column/Row/Text/Image/Button/Progress
  • 日志管理类的编写
  • 常量与资源分类的访问
  • @ohos.promptAction (弹窗)
  • MVVM模式

8.3.1.3 代码结构解读

├──entry/src/main/ets                   // 代码区
│  ├──common
│  │  ├──constants
│  │  │  └──CommonConstants.ets         // 公共常量类
│  │  └──utils
│  │     ├──Logger.ets                  // 日志工具类
│  │     ├──NotificationUtil.ets        // 通知工具类
│  │     └──ResourseUtil.ets            // 资源文件工具类
│  ├──entryability
│  │  └──EntryAbility.ets               // 程序入口类
│  └──pages
│     └──MainPage.ets                   // 主页面
└──entry/src/main/resources	        // 资源文件目录

8.3.1.4 公共文件与资源

本案例涉及到共常量类和日志类代码如下。

  1. 公共常量类(CommonConstants)
// main/ets/common/constants/CommonConstants.ets
export default class CommonConstants {
  static readonly FULL_LENGTH: string = '100%'
  static readonly TITLE_WIDTH: string = '86.7%'
  static readonly CARD_WIDTH: string = '93.3%'
  static readonly IMAGE_WEIGHT: number = 1
  static readonly CARD_CONTENT_WEIGHT: number = 5
  static readonly CARD_CONTENT_WIDTH: string = '70%'
  static readonly CARD_IMAGE_WIDTH: string = '30%'
  static readonly DOWNLOAD_FILE: string = '1653067.mp4'
  static readonly FILE_SIZE: string = '25.01MB'
  static readonly FONT_WEIGHT_LAGER: number = 500
  static readonly FONT_OPACITY: number = 0.6
  static readonly PROGRESS_TOTAL: number = 100
  static readonly PROGRESS_SPEED: number = 2
  static readonly UPDATE_FREQUENCY: number = 1000
  static readonly NOTIFICATION_ID: number = 1000
}

export enum DOWNLOAD_STATUS {
  INITIAL,
  DOWNLOADING,
  PAUSE,
  FINISHED
}
  1. 日志类(Logger)
// main/ets/common/utils/Logger.ets
import { hilog } from '@kit.PerformanceAnalysisKit'

class Logger {
  private domain: number
  private prefix: string
  private format: string = '%{public}s, %{public}s'
  constructor(prefix: string = 'MyApp', 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('DownLoadNotification', 0xFF00)

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

  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": "DownloadNotification"
    },
    {
      "name": "title",
      "value": "File DownLoad"
    },
    {
      "name": "button_download",
      "value": "DownLoad"
    },
    {
      "name": "button_pause",
      "value": "Pause"
    },
    {
      "name": "button_resume",
      "value": "Resume"
    },
    {
      "name": "button_cancel",
      "value": "Cancel"
    },
    {
      "name": "button_finish",
      "value": "Open"
    },
    {
      "name": "notification_title_download",
      "value": "Downloading"
    },
    {
      "name": "notification_title_pause",
      "value": "Download paused"
    },
    {
      "name": "notification_title_finish",
      "value": "Download completed"
    },
    {
      "name": "invalid_button_toast",
      "value": "The function is not implemented"
    },
    {
      "name": "invalid_progress_toast",
      "value": "The progress bar template is not supported"
    }
  ]
}
// main/resources/zh_CN/element/string.json
{
  "string": [
    {
      "name": "module_desc",
      "value": "模块描述"
    },
    {
      "name": "EntryAbility_desc",
      "value": "description"
    },
    {
      "name": "EntryAbility_label",
      "value": "下载通知"
    },
    {
      "name": "title",
      "value": "文件下载"
    },
    {
      "name": "button_download",
      "value": "下载"
    },
    {
      "name": "button_pause",
      "value": "暂停"
    },
    {
      "name": "button_resume",
      "value": "继续"
    },
    {
      "name": "button_cancel",
      "value": "取消"
    },
    {
      "name": "button_finish",
      "value": "打开"
    },
    {
      "name": "notification_title_download",
      "value": "下载中"
    },
    {
      "name": "notification_title_pause",
      "value": "已暂停下载"
    },
    {
      "name": "notification_title_finish",
      "value": "下载完成"
    },
    {
      "name": "invalid_button_toast",
      "value": "功能暂未实现"
    },
    {
      "name": "invalid_progress_toast",
      "value": "不支持进度条模板"
    }
  ]
}
  1. float.json
// main/resources/base/element/float.json
{
  "float": [
    {
      "name": "title_font_size",
      "value": "24fp"
    },
    {
      "name": "card_height",
      "value": "108vp"
    },
    {
      "name": "name_font_size",
      "value": "16fp"
    },
    {
      "name": "name_font_height",
      "value": "16vp"
    },
    {
      "name": "normal_font_size",
      "value": "14fp"
    },
    {
      "name": "title_margin_top",
      "value": "24vp"
    },
    {
      "name": "card_border_radius",
      "value": "16vp"
    },
    {
      "name": "card_padding",
      "value": "16vp"
    },
    {
      "name": "card_image_length",
      "value": "48vp"
    },
    {
      "name": "button_width",
      "value": "72vp"
    },
    {
      "name": "button_height",
      "value": "28vp"
    },
    {
      "name": "button_border_radius",
      "value": "14vp"
    },
    {
      "name": "button_margin",
      "value": "8vp"
    },
    {
      "name": "button_font_size",
      "value": "12fp"
    }
  ]
}
  1. color.json
// main/resources/base/element/color.json
{
  "color": [
    {
      "name": "start_window_background",
      "value": "#FFFFFF"
    },
    {
      "name": "index_background_color",
      "value": "#F1F3F5"
    },
    {
      "name": "button_color",
      "value": "#0A59F7"
    },
    {
      "name": "cancel_button_color",
      "value": "#0D000000"
    }
  ]
}

8.3.1.5 发送通知

发送通知需要完成以下步骤:

  1. 导入通知模块,查询系统是否支持进度条模板。
  2. 获取点击通知拉起应用时,需要的Want信息。
  3. 构造进度条模板对象,并发布通知。

具体代码如下:

// main/ets/common/utils/NotificationUtil.ets
import { wantAgent, common } from '@kit.AbilityKit'
import { notificationManager } from '@kit.NotificationKit'
import CommonConstants from '../constants/CommonConstants'
import Logger from '../utils/Logger'

/**
 * 创建WantAgent对象
 * @param bundleName 应用包名
 * @param abilityName Ability名称
 * @returns 返回一个Promise,解析为WantAgent对象
 */
export function createWantAgent(bundleName: string, abilityName: string): Promise<object> {
  // 定义WantAgent的配置信息
  let wantAgentInfo = {
    wants: [
      {
        bundleName: bundleName,  // 设置目标应用的包名
        abilityName: abilityName  // 设置目标Ability的名称
      }
    ],
    operationType: wantAgent.OperationType.START_ABILITY,  // 设置操作类型为启动Ability
    requestCode: 0,  // 设置请求码
    wantAgentFlags: [wantAgent.WantAgentFlags.CONSTANT_FLAG]  // 设置WantAgent的标志位
  } as wantAgent.WantAgentInfo

  // 调用wantAgent.getWantAgent方法创建WantAgent对象
  return wantAgent.getWantAgent(wantAgentInfo)
}

/**
 * 发布通知
 * @param progress 下载进度
 * @param title 通知标题
 * @param wantAgentObj WantAgent对象,用于通知点击跳转
 */
export function publishNotification(progress: number, title: string, wantAgentObj: object) {
  // 定义通知模板
  let template: notificationManager.NotificationTemplate = {
    name: 'downloadTemplate',  // 模板名称必须为downloadTemplate
    data: {
      title: `${title}`,  // 设置通知标题
      fileName: `${title}${CommonConstants.DOWNLOAD_FILE}`,  // 设置文件名
      progressValue: progress,  // 设置当前进度值
      progressMaxValue: CommonConstants.PROGRESS_TOTAL,  // 设置进度最大值
      isProgressIndeterminate: false  // 设置进度条是否为不确定状态
    }
  }

  // 定义通知请求
  let notificationRequest: notificationManager.NotificationRequest = {
    id: CommonConstants.NOTIFICATION_ID,  // 设置通知ID
    notificationSlotType: notificationManager.SlotType.CONTENT_INFORMATION,  // 设置通知槽类型
    template: template,  // 设置通知模板
    content: {
      notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,  // 设置通知内容类型
      normal: {
        title: `${title}${CommonConstants.DOWNLOAD_FILE}`,  // 设置通知标题
        text: ' ',  // 设置通知文本
        additionalText: `${progress}%`  // 设置附加文本(显示进度百分比)
      }
    },
    wantAgent: wantAgentObj  // 设置WantAgent对象
  }

  // 发布通知
  notificationManager.publish(notificationRequest).catch((err: Error) => {
    Logger.error(`[ANS] publish failed,message is ${err}`)  // 如果发布失败,记录错误日志
  })
}

/**
 * 打开通知权限
 * @param context UIAbilityContext对象
 */
export function openNotificationPermission(context: common.UIAbilityContext) {
  // 请求启用通知权限
  notificationManager.requestEnableNotification(context).then(() => {
    Logger.info('Enable notification success')  // 如果成功,记录日志
  }).catch((err: Error) => {
    Logger.error('Enable notification failed because ' + JSON.stringify(err))  // 如果失败,记录错误日志
  })
}

关键代码说明:

  1. createWantAgent ****函数
    • 用于创建一个 WantAgent 对象,该对象定义了点击通知后要执行的操作(如启动某个 Ability)。
    • 通过 wantAgent.getWantAgent 方法创建 WantAgent 对象。
  1. publishNotification ****函数
    • 用于发布一个带有进度条的通知。
    • 使用 notificationManager.NotificationTemplate 定义通知模板,支持显示下载进度。
    • 通过 notificationManager.publish 方法发布通知。
  1. openNotificationPermission ****函数
    • 用于请求用户启用通知权限。
    • 通过 notificationManager.requestEnableNotification 方法请求权限。

8.3.1.6 模拟下载

文件下载共有四种状态,分别为初始化、下载中、暂停下载、下载完成。主要实现以下功能:

  1. 初始化状态,点击下载,启动Interval定时器,持续发送通知。
  2. 下载中,点击暂停,清除定时器,发送一次通知,显示当前进度。
  3. 暂停下载,点击继续,重新启动定时器,重复步骤一。
  4. 下载完成,清除定时器。

具体页面代码如下:

// main/ets/pages/MainPage.ets
import { common } from '@kit.AbilityKit'  // 导入AbilityKit中的common模块,用于获取UIAbility上下文
import { promptAction } from '@kit.ArkUI'  // 导入ArkUI中的promptAction模块,用于显示提示信息
import { notificationManager } from '@kit.NotificationKit'  // 导入NotificationKit中的notificationManager模块,用于管理通知
import { createWantAgent, publishNotification, openNotificationPermission } from '../common/utils/NotificationUtil'  // 导入自定义的通知工具函数
import { getStringByRes } from '../common/utils/ResourseUtil'  // 导入资源工具函数,用于获取字符串资源
import Logger from '../common/utils/Logger'  // 导入日志工具
import CommonConstants, { DOWNLOAD_STATUS } from '../common/constants/CommonConstants'  // 导入常量定义

@Entry  // 标记为入口组件
@ComponentV2  // 标记为组件
struct MainPage {
  @Local downloadStatus: number = DOWNLOAD_STATUS.INITIAL  // 本地状态变量,表示下载状态,初始值为INITIAL
  @Local downloadProgress: number = 0  // 本地状态变量,表示下载进度,初始值为0
  private context = getContext(this) as common.UIAbilityContext  // 获取当前UIAbility的上下文
  private isSupport: boolean = true  // 是否支持下载模板通知
  private notificationTitle: string = ''  // 通知标题
  private wantAgentObj: object = new Object()  // WantAgent对象,用于通知点击后的跳转
  private interval: number = -1  // 定时器ID,用于控制下载进度更新

  // 生命周期函数,页面即将显示时调用
  aboutToAppear() {
    openNotificationPermission(this.context)  // 打开通知权限
    let bundleName = this.context.abilityInfo.bundleName  // 获取当前应用的包名
    let abilityName = this.context.abilityInfo.name  // 获取当前Ability的名称
    createWantAgent(bundleName, abilityName).then(want => {  // 创建WantAgent
      this.wantAgentObj = want  // 将创建的WantAgent对象赋值给wantAgentObj
    }).catch((err: Error) => {
      Logger.error(`getWantAgent fail, err: ${JSON.stringify(err)}`)  // 如果创建失败,记录错误日志
    })
    notificationManager.isSupportTemplate('downloadTemplate').then(isSupport => {  // 检查是否支持下载模板通知
      if (!isSupport) {
        promptAction.showToast({  // 如果不支持,显示提示信息
          message: $r('app.string.invalid_button_toast')
        })
      }
      this.isSupport = isSupport  // 更新isSupport状态
    })
  }

  // 返回按钮点击事件处理函数
  onBackPress() {
    this.cancel()  // 调用cancel方法取消下载
  }

  // 构建UI
  build() {
    Column() {
      Text($r('app.string.title'))  // 显示标题
        .fontSize($r('app.float.title_font_size'))
        .fontWeight(CommonConstants.FONT_WEIGHT_LAGER)
        .width(CommonConstants.TITLE_WIDTH)
        .textAlign(TextAlign.Start)
        .margin({
          top: $r('app.float.title_margin_top'),
          bottom: $r('app.float.title_margin_top')
        })
      Row() {
        Column() {
          Image($r('app.media.ic_image'))  // 显示图片
            .objectFit(ImageFit.Fill)
            .width($r('app.float.card_image_length'))
            .height($r('app.float.card_image_length'))
        }
        .layoutWeight(CommonConstants.IMAGE_WEIGHT)
        .height(CommonConstants.FULL_LENGTH)
        .alignItems(HorizontalAlign.Start)

        Column() {
          Row() {
            Text(CommonConstants.DOWNLOAD_FILE)  // 显示下载文件名
              .fontSize($r('app.float.name_font_size'))
              .textAlign(TextAlign.Center)
              .fontWeight(CommonConstants.FONT_WEIGHT_LAGER)
              .lineHeight($r('app.float.name_font_height'))
            Text(`${this.downloadProgress}%`)  // 显示下载进度百分比
              .fontSize($r('app.float.normal_font_size'))
              .lineHeight($r('app.float.name_font_height'))
              .opacity(CommonConstants.FONT_OPACITY)
          }
          .justifyContent(FlexAlign.SpaceBetween)
          .width(CommonConstants.FULL_LENGTH)

          Progress({  // 显示进度条
            value: this.downloadProgress,
            total: CommonConstants.PROGRESS_TOTAL
          })
            .width(CommonConstants.FULL_LENGTH)

          Row() {
            Text(CommonConstants.FILE_SIZE)  // 显示文件大小
              .fontSize($r('app.float.normal_font_size'))
              .lineHeight($r('app.float.name_font_height'))
              .opacity(CommonConstants.FONT_OPACITY)
            if (this.downloadStatus === DOWNLOAD_STATUS.INITIAL) {  // 根据下载状态显示不同的按钮
              this.customButton($r('app.string.button_download'), (): Promise<void> => this.start())
            } else if (this.downloadStatus === DOWNLOAD_STATUS.DOWNLOADING) {
              Row() {
                this.cancelButton()
                this.customButton($r('app.string.button_pause'), (): Promise<void> => this.pause())
              }
            } else if (this.downloadStatus === DOWNLOAD_STATUS.PAUSE) {
              Row() {
                this.cancelButton()
                this.customButton($r('app.string.button_resume'), (): Promise<void> => this.resume())
              }
            } else {
              this.customButton($r('app.string.button_finish'), (): void => this.open())
            }
          }
          .width(CommonConstants.FULL_LENGTH)
          .justifyContent(FlexAlign.SpaceBetween)
        }
        .layoutWeight(CommonConstants.CARD_CONTENT_WEIGHT)
        .height(CommonConstants.FULL_LENGTH)
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width(CommonConstants.CARD_WIDTH)
      .height($r('app.float.card_height'))
      .backgroundColor(Color.White)
      .borderRadius($r('app.float.card_border_radius'))
      .justifyContent(FlexAlign.SpaceBetween)
      .padding($r('app.float.card_padding'))
    }
    .width(CommonConstants.FULL_LENGTH)
    .height(CommonConstants.FULL_LENGTH)
    .backgroundColor($r('app.color.index_background_color'))
  }

  // 下载函数,使用定时器模拟下载进度
  download() {
    this.interval = setInterval(async () => {
      if (this.downloadProgress === CommonConstants.PROGRESS_TOTAL) {  // 如果下载完成
        this.notificationTitle = await getStringByRes($r('app.string.notification_title_finish'), this)  // 获取完成通知标题
        this.downloadStatus = DOWNLOAD_STATUS.FINISHED  // 更新下载状态为完成
        clearInterval(this.interval)  // 清除定时器
      } else {
        this.downloadProgress += CommonConstants.PROGRESS_SPEED  // 更新下载进度
      }
      if (this.isSupport) {
        publishNotification(this.downloadProgress, this.notificationTitle, this.wantAgentObj)  // 发布通知
      }
    }, CommonConstants.UPDATE_FREQUENCY)  // 定时器间隔时间
  }

  // 开始下载
  async start() {
    this.notificationTitle = await getStringByRes($r('app.string.notification_title_download'), this)  // 获取下载通知标题
    this.downloadStatus = DOWNLOAD_STATUS.DOWNLOADING  // 更新下载状态为下载中
    this.downloadProgress = 0  // 重置下载进度
    this.download()  // 调用下载函数
  }

  // 暂停下载
  async pause() {
    this.notificationTitle = await getStringByRes($r('app.string.notification_title_pause'), this)  // 获取暂停通知标题
    clearInterval(this.interval)  // 清除定时器
    this.downloadStatus = DOWNLOAD_STATUS.PAUSE  // 更新下载状态为暂停
    if (this.isSupport) {
      publishNotification(this.downloadProgress, this.notificationTitle, this.wantAgentObj)  // 发布通知
    }
  }

  // 恢复下载
  async resume() {
    this.notificationTitle = await getStringByRes($r('app.string.notification_title_download'), this)  // 获取下载通知标题
    this.download()  // 调用下载函数
    this.downloadStatus = DOWNLOAD_STATUS.DOWNLOADING  // 更新下载状态为下载中
  }

  // 取消下载
  async cancel() {
    this.downloadProgress = 0  // 重置下载进度
    clearInterval(this.interval)  // 清除定时器
    this.downloadStatus = DOWNLOAD_STATUS.INITIAL  // 更新下载状态为初始状态
    notificationManager.cancel(CommonConstants.NOTIFICATION_ID)  // 取消通知
  }

  // 打开文件
  open() {
    promptAction.showToast({  // 显示提示信息
      message: $r('app.string.invalid_button_toast')
    })
  }

  // 自定义按钮
  @Builder
  customButton(textResource: Resource, click: Function = () => {
  }) {
    Button(textResource)
      .backgroundColor($r('app.color.button_color'))
      .buttonsStyle()
      .onClick(() => {
        click()
      })
  }

  // 取消按钮
  @Builder
  cancelButton() {
    Button($r('app.string.button_cancel'))
      .buttonsStyle()
      .backgroundColor($r('app.color.cancel_button_color'))
      .fontColor($r('app.color.button_color'))
      .margin({ right: $r('app.float.button_margin') })
      .onClick(() => {
        this.cancel()
      })
  }
}

// 扩展Button组件样式
@Extend(Button)
function buttonsStyle() {
  .constraintSize({ minWidth: $r('app.float.button_width') })
  .height($r('app.float.button_height'))
  .borderRadius($r('app.float.button_border_radius'))
  .fontSize($r('app.float.button_font_size'))
}

关键代码说明:

  1. UI部分
    • 使用了ColumnRowTextImageProgress等组件来构建页面布局。
    • 根据downloadStatus的不同状态,显示不同的按钮(下载、暂停、继续、取消、完成)。
  1. 逻辑部分
    • aboutToAppear:页面显示时初始化通知权限、WantAgent,并检查是否支持下载模板通知。
    • download:使用setInterval模拟下载进度,并根据进度更新通知。
    • startpauseresumecancel:分别处理下载的开始、暂停、继续和取消操作。
    • open:处理下载完成后的操作(目前只是显示一个提示)。
  1. 工具函数
    • createWantAgentpublishNotificationopenNotificationPermission:用于创建WantAgent、发布通知和打开通知权限。
    • getStringByRes:用于获取资源文件中的字符串。
  1. 样式扩展
    • buttonsStyle:扩展了Button组件的样式,统一了按钮的宽度、高度、圆角和字体大小。

8.3.1.7 工具函数

该函数是一个通用的工具函数,用于从资源文件中获取字符串资源。通过异步方式获取资源,确保在资源加载完成后再进行后续操作。同时,加入了错误处理机制,增强了代码的健壮性。

import Logger from '../utils/Logger'  // 导入日志工具,用于记录错误信息

/**
 * 根据资源ID获取字符串资源
 * @param resource 资源对象,包含资源ID等信息
 * @param component 组件对象,用于获取上下文
 * @returns 返回获取到的字符串资源,如果资源无效则返回空字符串
 */
export async function getStringByRes(resource: Resource, component: Object): Promise<string> {
  // 检查资源是否有效
  if (!resource) {
    Logger.error('getStringByRes resource is invalid')  // 如果资源无效,记录错误日志
    return ''  // 返回空字符串
  }

  // 获取组件上下文中的资源管理器,并调用getStringValue方法获取字符串资源
  let string = await getContext(component).resourceManager.getStringValue(resource.id)

  return string  // 返回获取到的字符串
}

8.3.1.8 代码与视频教程

完整案例代码与视频教程请参见:

代码:Code-08-01.zip。

视频:《实现进度条通知》。

8.3.2 实现验证码登录

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

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

8.3.2.1 案例效果截图

8.3.2.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模式

8.3.2.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         // 资源文件目录

8.3.2.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'
}
  1. 全局上下文单例模式类(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)
  }
}
  1. 日志类(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": "首页"
    }
  ]
}
  1. 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"
    }
  ]
}
  1. 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"
    }
  ]
}

8.3.2.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页面。
  1. 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方法。

8.3.2.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页面。
  1. 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页面。

8.3.2.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。
  1. 验证项数据模型
export default class VerifyItem {
  image: Resource
  characters: string
  constructor(image: Resource, characters: string) {
    this.image = image
    this.characters = characters
  }
}
  1. 窗口视图模型
// 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.3.2.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事件。

8.3.2.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
  }
}
  1. 构建首页面的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()

8.3.2.10 代码与视频教程

完整案例代码与视频教程请参见:

代码:Code-08-02.zip。

视频:《实现验证码登录》。

8.4 本章小节

本章重点介绍了 HarmonyOS 开发中通知与窗口管理的关键知识。在通知领域,从基础概念到复杂操作,涵盖了通知发布、管理的全流程,开发者可依据不同场景灵活选择和处理通知,如进度条通知在文件下载场景的应用。窗口管理部分,清晰区分了系统窗口与应用窗口,详细说明了主窗口和子窗口的设置方式以及沉浸式效果的实现,这些能力为优化用户界面体验提供了有力支持,像验证码登录案例中登录页面的沉浸式设计和子窗口的运用。通过理论与案例结合的方式,开发者能够深入理解并将这些知识运用到实际开发中,打造出功能丰富、体验良好的应用程序,满足用户多样化的需求,提升应用在 HarmonyOS 生态中的竞争力。