HarmonyOS ArkTS 倒计时组件实战:设计模式实践篇 - 标志位驱动渲染与状态机模式

34 阅读9分钟

HarmonyOS ArkTS 倒计时组件实战:设计模式实践篇 - 标志位驱动渲染与状态机模式

本文是《HarmonyOS ArkTS 企业级倒计时组件设计与实现》系列的第三篇,将深入探讨标志位驱动渲染模式和状态机模式的实际应用。适合中级开发者和架构师,学习如何在实际项目中应用设计模式。

📋 前言

在第二篇文章中,我们讨论了过度设计的问题:AI 给出的是更贴近 GoF 的“上下文+选择器+策略对象”的重型策略实现,而我们的场景映射/Record 版本其实也是策略模式的轻量实现。这里继续延伸,探讨在倒计时组件中真正适用且足够轻量的模式:标志位驱动渲染状态机模式(二者同样体现“条件→行为/显示”的策略思想,只是以数据/表驱动的形式落地)。

这两种模式在倒计时组件中发挥了重要作用,既保持了代码简洁,又提供了良好的可维护性。

🎯 标志位驱动渲染模式

什么是标志位驱动渲染?

标志位驱动渲染是一种通过布尔值标志位控制UI元素显示/隐藏的设计模式。在倒计时组件中,我们使用 Record<string, boolean> 来管理所有显示元素的标志位。

设计思路

倒计时组件需要显示多种时间格式,每种格式包含不同的元素:

  • 数字:D、hh、mm、ss、ms
  • 分隔符::1、:2、.
  • 文字:天、小时

如果使用传统的 if-else 判断,代码会非常冗长:

// 传统方式(不推荐)
if (showDay) {
  Text(this.D.toString())
}
if (showDay && showDayText) {
  Text('天')
}
if (showHour) {
  Text(this.hh.toString())
}
// ... 大量重复的if判断

使用标志位驱动,代码更简洁:

@Local timeFormatFlags: Record<string, boolean> = {
  'D': false,
  '天': false,
  'hh': !this.shouldHideHour ?? false,
  ':1': !this.shouldHideHour ?? false,
  '小时': false,
  'mm': false,
  ':2': false,
  'ss': false,
  '.': this.shouldShowMS ?? false,
  'ms': this.shouldShowMS ?? false,
}

build() {
  Row() {
    if (this.timeFormatFlags['D']) {
      Text(this.D.toString())
    }
    if (this.timeFormatFlags['天']) {
      Text('天')
    }
    if (this.timeFormatFlags['hh']) {
      Text(this.padZero(this.hh))
    }
    // ... 统一的模式
  }
}

核心实现

1. 标志位初始化
@Local timeFormatFlags: Record<string, boolean> = {
  '自定义文本': this.activeCustomizedText ? true : false,
  'D': false,
  '天': false,
  'hh': !this.shouldHideHour ?? false,
  ':1': !this.shouldHideHour ?? false,
  '小时': false,
  'mm': false,
  ':2': false,
  'ss': false,
  '.': this.shouldShowMS ?? false,
  'ms': this.shouldShowMS ?? false,
}

设计要点:

  • 使用字符串键,语义清晰
  • 初始值根据配置设置
  • 后续通过 assignFlags 方法动态更新
2. 标志位更新方法
assignFlags(keyList: string) {
  this.keyList = keyList // 保存格式方案
  
  // 使用字符串中的键查询并开启布尔值
  Object.keys(this.timeFormatFlags).forEach((key): boolean =>
    (this.timeFormatFlags[key] = keyList.includes(key))
  )
}

工作原理:

  • 输入:格式字符串,如 'hh:1mm:2ss'
  • 处理:遍历所有标志位,检查是否在格式字符串中
  • 输出:更新标志位,控制UI显示

示例:

// 输入:'hh:1mm:2ss'
// 处理后:
// timeFormatFlags['hh'] = true
// timeFormatFlags[':1'] = true
// timeFormatFlags['mm'] = true
// timeFormatFlags[':2'] = true
// timeFormatFlags['ss'] = true
// 其他 = false
3. UI渲染
build() {
  Row() {
    // 前缀
    if (this.customStyleEnabled) {
      Text(this.activePre)
        .exNumber(this.fontSize, this.decorationFontColor, this.fontWeight)
    }
    
    // 自定义文本
    if (this.timeFormatFlags['自定义文本']) {
      Text(this.activeCustomizedText)
        .exNumber(this.fontSize, this.customizedFontColor, this.fontWeight)
    }
    
    // 天
    if (this.timeFormatFlags['D']) {
      Text(this.D.toString())
        .exNumber(this.fontSize, this.numberFontColor, this.fontWeight, 
                  this.numberBgColor, this.numberBgRadius)
    }
    if (this.timeFormatFlags['天']) {
      Text('天')
        .exNumber(this.fontSize, this.separatorFontColor, this.fontWeight)
    }
    
    // 小时
    if (this.timeFormatFlags['hh']) {
      Text(this.padZero(this.hh))
        .exNumber(this.fontSize, this.numberFontColor, this.fontWeight, 
                  this.numberBgColor, this.numberBgRadius)
    }
    if (this.timeFormatFlags['小时']) {
      Text('小时')
        .exNumber(this.fontSize, this.separatorFontColor, this.fontWeight)
    }
    if (this.timeFormatFlags[':1']) {
      Text(':')
        .exNumber(this.fontSize, this.separatorFontColor, this.fontWeight)
    }
    
    // 分钟
    if (this.timeFormatFlags['mm']) {
      Text(this.padZero(this.mm))
        .exNumber(this.fontSize, this.numberFontColor, this.fontWeight, 
                  this.numberBgColor, this.numberBgRadius)
    }
    if (this.timeFormatFlags[':2']) {
      Text(':')
        .exNumber(this.fontSize, this.separatorFontColor, this.fontWeight)
    }
    
    // 秒和毫秒
    Row() {
      if (this.timeFormatFlags['ss']) {
        Text(this.padZero(this.ss))
          .exNumber(this.fontSize, this.numberFontColor, this.fontWeight)
      }
      if (this.timeFormatFlags['.']) {
        Text('.')
          .exNumber(this.fontSize, this.numberFontColor, this.fontWeight)
      }
      if (this.timeFormatFlags['ms']) {
        Text(this.ms.toString())
          .exNumber(this.fontSize, this.numberFontColor, this.fontWeight)
      }
    }
    .borderRadius(this.numberBgRadius)
    .backgroundColor(this.numberBgColor)
    
    // 后缀
    if (this.customStyleEnabled && this.activeSuf) {
      Text(this.activeSuf)
        .exNumber(this.fontSize, this.decorationFontColor, this.fontWeight)
    }
  }
  .position({ top: this.y, left: this.x })
}

优势分析

1. 代码简洁性

对比传统方式:

// 传统方式:需要大量if-else判断
if (format === 'hh:mm:ss') {
  if (showHour) {
    Text(this.hh)
    if (showSeparator) Text(':')
  }
  if (showMinute) {
    Text(this.mm)
    if (showSeparator) Text(':')
  }
  // ... 大量嵌套
}

标志位驱动:

// 统一模式,代码清晰
if (this.timeFormatFlags['hh']) {
  Text(this.padZero(this.hh))
}
if (this.timeFormatFlags[':1']) {
  Text(':')
}
2. 易于扩展

新增格式只需:

  1. timeFormatFlags 中添加新标志位(如需要)
  2. assignFlags 中处理新格式字符串
  3. build 中添加对应的UI元素

无需修改大量if-else逻辑。

3. 可维护性
  • 标志位集中管理,易于理解
  • 格式字符串语义清晰('hh:1mm:2ss'
  • 修改格式只需更新标志位,不影响UI结构

与场景映射方案的对比

在第二篇文章中,我们使用了场景映射方案:

const occasions: Record<string, string> = {
  '禁用自定义': above48h ? 'D天hh小时' : 'hh:1mm:2ss',
  '启用自定义0': minuteForm,
  // ...
}

两种方案的对比:

维度标志位驱动场景映射
适用场景UI元素多,需要精细控制格式选择逻辑复杂
代码量中等较少
可读性高(标志位语义清晰)高(场景映射直观)
扩展性高(添加标志位即可)高(添加场景即可)
维护成本中等

结论: 两种方案都是优秀的设计,选择取决于具体场景。在倒计时组件中,我们结合使用了两种方案:

  • 场景映射:选择格式(decideWhatToShow
  • 标志位驱动:控制UI显示(build

🔄 状态机模式

什么是状态机?

状态机是一种管理对象状态转换的设计模式。在倒计时组件中,我们使用状态机管理定时器的状态。

状态定义

enum TimerStatus {
  IDLE = 0,      // 空闲:未启动或已停止
  RUNNING = 1,   // 运行中:正在倒计时
  PAUSED = 2     // 暂停:已暂停,可以恢复
}

状态转换图

        startCountdown()
    IDLE ──────────────→ RUNNING
     ↑                      │
     │                      │ pauseCountdown()
     │                      ↓
     │                  PAUSED
     │                      │
     │                      │ startCountdown()
     │                      ↓
     └────────────────── RUNNING
        stopCountdown()

状态转换实现

1. 启动倒计时
startCountdown(): void {
  // 如果正在运行,直接返回(防止重复启动)
  if (this.status === TimerStatus.RUNNING) {
    return
  }
  
  // 状态转换:IDLE/PAUSED → RUNNING
  this.status = TimerStatus.RUNNING
  
  // 清除之前的定时器
  if (this.timerId !== 0) {
    clearInterval(this.timerId)
  }
  
  // 启动新定时器
  this.timerId = setInterval(() => {
    // ... 倒计时逻辑
  }, this.interval)
}

关键点:

  • 状态检查:防止重复启动
  • 状态转换:明确的状态变更
  • 资源管理:清理旧定时器
2. 暂停倒计时
pauseCountdown(): void {
  // 只有运行中才能暂停
  if (this.status === TimerStatus.RUNNING) {
    // 状态转换:RUNNING → PAUSED
    this.status = TimerStatus.PAUSED
    
    // 清理定时器
    if (this.timerId !== 0) {
      clearInterval(this.timerId)
      this.timerId = 0
    }
  }
  
  // 刷新显示
  this.refreshTimeNumber(this.remainingTime)
}

关键点:

  • 状态检查:只有运行中才能暂停
  • 状态转换:明确的状态变更
  • 资源清理:释放定时器资源
3. 停止倒计时
stopCountdown(): void {
  // 状态转换:任意状态 → IDLE
  this.status = TimerStatus.IDLE
  
  // 清理定时器
  if (this.timerId !== 0) {
    clearInterval(this.timerId)
    this.timerId = 0
  }
  
  // 重置时间
  this.remainingTime = 0
  this.refreshTimeNumber(this.remainingTime)
}

状态机的优势

1. 状态管理清晰

使用枚举定义状态,避免魔法数字:

// 不好的做法
if (this.status === 1) { // 1是什么?
  // ...
}

// 好的做法
if (this.status === TimerStatus.RUNNING) {
  // ...
}
2. 防止状态混乱

通过状态检查,防止非法状态转换:

// 防止重复启动
if (this.status === TimerStatus.RUNNING) {
  return
}

// 只有运行中才能暂停
if (this.status === TimerStatus.RUNNING) {
  this.status = TimerStatus.PAUSED
}
3. 易于调试

状态明确,便于日志记录和调试:

Logger.info(TAG, `状态转换:${this.status}${TimerStatus.RUNNING}`)

实际应用场景

1. 页面生命周期管理
aboutToAppear(): void {
  this.startCountdown() // IDLE → RUNNING
  // ...
}

aboutToDisappear(): void {
  this.stopCountdown() // RUNNING → IDLE
  // ...
}
2. 页面显示/隐藏事件
if (!this.ignorePause) {
  emitter.on(PAGE_SHOW_EVENT, () => {
    this.startCountdown() // PAUSED → RUNNING
  })
  emitter.on(PAGE_HIDE_EVENT, () => {
    this.pauseCountdown() // RUNNING → PAUSED
  })
}
3. 倒计时结束
if (this.remainingTime <= 0) {
  this.stopCountdown() // RUNNING → IDLE
  this.timeOutAction()
}

🎯 设计模式的选择原则

什么时候用设计模式?

✅ 适合使用设计模式的场景
  1. 问题复杂度高:需要管理多个状态、多种策略
  2. 扩展需求明确:未来需要频繁扩展
  3. 代码重复多:有大量重复逻辑
  4. 维护成本高:当前代码难以维护
❌ 不适合使用“重型/OOP”实现的场景
  1. 问题简单:简单的 if-else 或轻量表驱动即可
  2. 扩展需求不明确:未来扩展可能性低
  3. 代码量增加明显:重型实现引入大量样板代码
  4. 过度设计:为了用设计模式而用设计模式

倒计时组件中的设计模式应用

设计模式应用场景是否适合理由
标志位驱动UI元素显示控制✅ 适合元素多,需要精细控制
状态机定时器状态管理✅ 适合状态转换复杂,需要清晰管理
策略模式(轻量/表驱动)格式选择逻辑✅ 适合逻辑简单,用场景映射/Record 轻量实现
策略模式(重型/OOP)同上❌ 不适合当前场景场景简单,重型实现代码量大、收益低

与第二篇的呼应

  • 第二篇结论:AI 给出的 GoF/OOP 重型策略在本场景“成本过高”;场景映射/Record 版本仍然是策略模式,但更轻量。
  • 本文延伸:
    • 标志位驱动:解决 UI 元素多、精细控制的问题,轻量且清晰。
    • 状态机:解决状态转换复杂的问题,逻辑清晰。
    • 轻量策略/表驱动:格式选择逻辑用场景映射实现策略意图,而非 OOP 仪式。

关键区别:

  • 重型/OOP 策略:为“优雅/形式”增加复杂度,当前场景收益低。
  • 轻量策略(场景映射/表驱动)、标志位驱动、状态机:为解决实际问题而用,成本低、收益高。

📊 设计模式对比分析

标志位驱动 vs 传统if-else

维度传统if-else标志位驱动
代码量多(大量嵌套)少(统一模式)
可读性低(嵌套深)高(语义清晰)
扩展性低(需要修改多处)高(添加标志位)
维护性低(逻辑分散)高(集中管理)

状态机 vs 布尔标志

维度布尔标志状态机
状态数量适合2-3个状态适合3+个状态
状态转换不清晰清晰
防止错误强(状态检查)
可维护性中等

💡 最佳实践

1. 标志位驱动的实践

  • ✅ 使用语义化的键名('hh'':1' 而不是 'flag1''flag2'
  • ✅ 集中管理标志位(使用 Record<string, boolean>
  • ✅ 通过字符串模式更新标志位(assignFlags
  • ❌ 避免在多个地方直接修改标志位

2. 状态机的实践

  • ✅ 使用枚举定义状态(避免魔法数字)
  • ✅ 明确状态转换条件(在方法中检查)
  • ✅ 记录状态转换日志(便于调试)
  • ❌ 避免跳过状态检查(防止状态混乱)

3. 设计模式的选择

  • ✅ 先分析问题,再选择模式
  • ✅ 评估代码量增加是否值得
  • ✅ 考虑未来扩展需求
  • ❌ 不要为了用设计模式而用设计模式

🎓 总结

本文探讨了倒计时组件中真正适用的设计模式:

  1. 标志位驱动渲染:解决了UI元素多、需要精细控制的问题
  2. 状态机模式:解决了状态管理复杂、需要清晰转换的问题

这两种模式都:

  • ✅ 解决了实际问题
  • ✅ 代码简洁清晰
  • ✅ 易于维护扩展
  • ✅ 没有过度设计

与第二篇文章形成呼应:不是所有设计模式都适合,要选择真正解决问题的模式

在下一篇文章中,我们将探讨性能优化的实战技巧,包括渲染优化、定时器优化和内存管理。


系列文章导航:

  • [第1篇] 基础篇:从需求分析到基础实现
  • [第2篇] 设计模式反思篇:当AI建议用策略模式时,我选择了质疑
  • [第3篇] 设计模式实践篇:标志位驱动渲染与状态机模式(本文)
  • [第4篇] 性能优化篇:从100ms刷新到流畅体验
  • [第5篇] 高级特性篇:时间区间样式切换
  • [第6篇] 工程实践篇:测试与质量保证
  • [第7篇] 总结篇:最佳实践与思考

讨论: 你在项目中是如何选择设计模式的?欢迎在评论区分享你的经验!