一次重构引发的思考

最近在重构一个HarmonyOS应用时,我发现项目中到处都是类似的气泡弹窗代码。每个页面都有自己的bindPopup调用,配置参数散落在各个角落。当我需要统一修改弹窗样式时,不得不搜索整个项目逐个修改——这简直就是维护者的噩梦。

更糟糕的是,由于每个开发者对弹窗的理解不同,同样的提示信息在不同页面显示效果却不一致:有的弹窗箭头大小不对,有的背景模糊效果没开,有的甚至忘记监听关闭事件。这让我意识到,必须找到一个系统性的解决方案。

问题现象

在HarmonyOS应用开发中,bindPopup是创建气泡弹窗的常用方法。但在实际项目中,我们经常会遇到以下问题:

  1. 代码重复严重:相同的气泡弹窗配置在不同页面重复出现

  2. 样式不统一:相似功能的弹窗在不同页面展示效果不一致

  3. 维护困难:修改弹窗样式需要逐个文件搜索修改

  4. 事件监听混乱:弹窗状态管理逻辑分散在各个组件中

典型问题代码示例

// Page1.ets
Text('按钮1')
  .bindPopup(this.showPopup1, {
    builder: () => {
      Text('提示信息1')
        .fontSize(13)
        .padding({ left: 15, right: 15, top: 10, bottom: 10 })
    },
    placement: Placement.Bottom,
    offset: { y: -6 },
    arrowWidth: 10,
    arrowHeight: 6,
    // ... 其他配置
  })

// Page2.ets - 同样的弹窗,但配置略有不同
Text('按钮2')
  .bindPopup(this.showPopup2, {
    builder: () => {
      Text('提示信息2')
        .fontSize(13)
        .padding({ left: 15, right: 15, top: 10, bottom: 10 })
    },
    placement: Placement.Bottom,
    offset: { y: -6 },
    arrowWidth: 12, // 箭头宽度不同!
    arrowHeight: 6,
    // ... 其他配置
  })

问题分析

要解决上述问题,我们需要深入分析bindPopup的配置特性:

  1. PopupOptions的结构复杂性bindPopup接收的PopupOptions对象包含多个配置项,包括builderplacementoffsetarrowWidtharrowHeightpopupColorbackgroundBlurStyleonStateChange等。这些配置项的合理组合才能创建出符合设计规范的气泡弹窗。

  2. 事件监听的必要性:气泡弹窗的状态变化(显示/隐藏)需要被监听,以便在适当时机执行清理操作或更新UI状态。但手动在每个弹窗中添加监听逻辑会导致代码重复。

  3. emitter的作用:HarmonyOS的emitter模块提供了一种轻量级的事件发布/订阅机制,可以在同一线程或进程内进行通信。利用emitter可以实现弹窗状态变化的集中管理。

  4. 封装的关键点:封装的目标不仅是减少代码重复,更重要的是提供一致的用户体验、简化状态管理、并支持灵活的定制。

解决方案

基于上述分析,我们可以创建一个专门的工具类来封装bindPopup的配置,实现"一次封装,多处复用"。

核心封装思路

  1. 创建统一的配置生成函数:将弹窗的通用配置(如箭头大小、偏移量、背景模糊等)封装在一个函数中

  2. 支持动态内容:通过参数传递弹窗内容,支持文本、颜色等动态变化

  3. 集成状态管理:利用emitter统一管理弹窗的显示/隐藏状态

  4. 提供类型安全:使用TypeScript接口确保配置的类型安全

详细实现步骤

步骤1:创建工具类文件

首先创建一个独立的工具类文件CustomPopUtility.ets

// CustomPopUtility.ets
import { emitter } from '@kit.BasicServicesKit';

/**
 * 自定义弹窗配置接口
 */
export interface CustomPopupOptions {
  builder: () => void;
  placement?: Placement;
  offset?: { dx?: number, dy?: number };
  arrowWidth?: number;
  arrowHeight?: number;
  backgroundBlurStyle?: BlurStyle;
  popupColor?: ResourceColor;
  onStateChange?: (isVisible: boolean) => void;
}

/**
 * 弹窗内容构建器
 * @param title 弹窗标题文本
 * @param color 文本颜色
 * @returns 弹窗内容组件
 */
@Builder
function csPopupBuilder(title: string | Resource, color: string) {
  Column() {
    Text(title)
      .fontSize(13)
      .padding({ left: 15, right: 15, top: 10, bottom: 10 })
      .fontColor(color)
      .onDisAppear(() => {
        // 弹窗消失时发送事件通知
        emitter.emit({
          eventId: 1,  // 自定义事件ID
          priority: emitter.EventPriority.HIGH
        });
      });
  }
}

/**
 * 构建弹窗配置参数
 * @param that 当前组件实例的this
 * @param content 弹窗内容文本
 * @param customPopup 弹窗状态变量
 * @param options 可选的自定义配置
 * @returns 完整的PopupOptions配置
 */
export function buildOptionsParams(
  that: Object, 
  content: string, 
  customPopup: boolean,
  options?: {
    placement?: Placement;
    backgroundColor?: string;
    textColor?: string;
    offsetY?: number;
  }
): CustomPopupOptions {
  // 默认配置
  const defaultOptions = {
    placement: Placement.Bottom,
    backgroundColor: '#5291FF',
    textColor: '#FFFFFF',
    offsetY: -6
  };
  
  // 合并用户配置和默认配置
  const mergedOptions = { ...defaultOptions, ...options };
  
  return {
    builder: csPopupBuilder.bind(that, content, mergedOptions.textColor),
    placement: mergedOptions.placement,
    offset: { y: mergedOptions.offsetY },
    arrowWidth: 10,
    arrowHeight: 6,
    backgroundBlurStyle: BlurStyle.NONE,
    popupColor: mergedOptions.backgroundColor,
    onStateChange: (e) => {
      if (!e.isVisible) {
        // 弹窗隐藏时更新状态
        customPopup = false;
      } else {
        // 弹窗显示时更新状态
        customPopup = true;
      }
      console.info('弹窗状态变化: ', customPopup);
    },
  };
}
步骤2:创建主题配置

为了支持不同的应用主题,我们可以创建主题配置文件:

// PopupTheme.ets
/**
 * 弹窗主题配置
 */
export class PopupTheme {
  // 默认主题
  static readonly DEFAULT = {
    backgroundColor: '#5291FF',
    textColor: '#FFFFFF',
    arrowWidth: 10,
    arrowHeight: 6,
    offsetY: -6
  };
  
  // 成功主题
  static readonly SUCCESS = {
    backgroundColor: '#34C759',
    textColor: '#FFFFFF',
    arrowWidth: 10,
    arrowHeight: 6,
    offsetY: -6
  };
  
  // 警告主题
  static readonly WARNING = {
    backgroundColor: '#FF9500',
    textColor: '#FFFFFF',
    arrowWidth: 10,
    arrowHeight: 6,
    offsetY: -6
  };
  
  // 错误主题
  static readonly ERROR = {
    backgroundColor: '#FF3B30',
    textColor: '#FFFFFF',
    arrowWidth: 10,
    arrowHeight: 6,
    offsetY: -6
  };
  
  // 深色主题
  static readonly DARK = {
    backgroundColor: '#1C1C1E',
    textColor: '#FFFFFF',
    arrowWidth: 10,
    arrowHeight: 6,
    offsetY: -6
  };
}
步骤3:在页面中使用封装后的弹窗
// Index.ets
import { emitter } from '@kit.BasicServicesKit';
import { buildOptionsParams } from './CustomPopUtility';
import { PopupTheme } from './PopupTheme';

@Entry
@Component
struct Index {
  @State isShowSchedulePopupGuide: boolean = false;
  @State isShowSuccessPopup: boolean = false;
  @State isShowWarningPopup: boolean = false;

  aboutToAppear(): void {
    // 订阅弹窗关闭事件
    emitter.on({
      eventId: 1
    }, (eventData: emitter.EventData) => {
      if (eventData) {
        // 当收到弹窗关闭事件时,统一重置所有弹窗状态
        this.isShowSchedulePopupGuide = false;
        this.isShowSuccessPopup = false;
        this.isShowWarningPopup = false;
        
        // 可以在这里执行弹窗关闭后的统一逻辑
        console.info('弹窗已关闭,执行清理操作');
      }
    });
  }

  aboutToDisappear(): void {
    // 组件销毁时取消订阅
    emitter.off(1);
  }

  build() {
    Column() {
      // 示例1:默认主题的弹窗
      Text('温馨提示')
        .fontSize(25)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 50 })
        .onClick(() => {
          this.isShowSchedulePopupGuide = !this.isShowSchedulePopupGuide;
        })
        .bindPopup(
          this.isShowSchedulePopupGuide,
          buildOptionsParams(
            this,
            '国庆中秋假期10月1日至8日放假调休,共8天。所有收费公路(含机场高速、收费桥梁和隧道)免征小型客车通行费',
            this.isShowSchedulePopupGuide
          )
        )
      
      // 示例2:成功主题的弹窗
      Text('操作成功提示')
        .fontSize(25)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 50 })
        .onClick(() => {
          this.isShowSuccessPopup = !this.isShowSuccessPopup;
        })
        .bindPopup(
          this.isShowSuccessPopup,
          buildOptionsParams(
            this,
            '数据保存成功!',
            this.isShowSuccessPopup,
            {
              backgroundColor: PopupTheme.SUCCESS.backgroundColor,
              textColor: PopupTheme.SUCCESS.textColor
            }
          )
        )
      
      // 示例3:警告主题的弹窗
      Text('警告提示')
        .fontSize(25)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 50 })
        .onClick(() => {
          this.isShowWarningPopup = !this.isShowWarningPopup;
        })
        .bindPopup(
          this.isShowWarningPopup,
          buildOptionsParams(
            this,
            '确认要删除这条记录吗?删除后不可恢复。',
            this.isShowWarningPopup,
            {
              backgroundColor: PopupTheme.WARNING.backgroundColor,
              textColor: PopupTheme.WARNING.textColor,
              placement: Placement.Top,  // 可以自定义位置
              offsetY: 6  // 可以自定义偏移
            }
          )
        )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Start)
    .alignItems(HorizontalAlign.Center);
  }
}

进阶封装:支持更复杂的弹窗内容

如果需要支持更复杂的弹窗内容(如图标+文本的组合),可以进一步扩展:

// AdvancedPopupUtility.ets
import { emitter } from '@kit.BasicServicesKit';

/**
 * 复杂弹窗内容接口
 */
export interface ComplexPopupContent {
  icon?: Resource;
  title: string;
  message: string;
  showCloseButton?: boolean;
}

/**
 * 复杂弹窗构建器
 */
@Builder
function complexPopupBuilder(content: ComplexPopupContent, theme: any) {
  Column({ space: 8 }) {
    if (content.icon) {
      Image(content.icon)
        .width(24)
        .height(24)
        .margin({ bottom: 8 })
    }
    
    Text(content.title)
      .fontSize(16)
      .fontColor(theme.textColor)
      .fontWeight(FontWeight.Medium)
    
    Text(content.message)
      .fontSize(13)
      .fontColor(theme.textColor)
      .opacity(0.8)
      .maxLines(3)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
    
    if (content.showCloseButton) {
      Button('关闭')
        .width(60)
        .height(28)
        .margin({ top: 12 })
        .backgroundColor(Color.White)
        .fontColor(theme.backgroundColor)
        .onClick(() => {
          emitter.emit({
            eventId: 2,  // 关闭按钮点击事件
            priority: emitter.EventPriority.HIGH
          });
        })
    }
  }
  .padding(16)
  .onDisAppear(() => {
    emitter.emit({
      eventId: 1,
      priority: emitter.EventPriority.HIGH
    });
  });
}

/**
 * 构建复杂弹窗配置
 */
export function buildComplexPopupOptions(
  that: Object,
  content: ComplexPopupContent,
  isVisible: boolean,
  themeType: string = 'default'
): any {
  const theme = getTheme(themeType);
  
  return {
    builder: () => {
      complexPopupBuilder(content, theme);
    },
    placement: Placement.Bottom,
    offset: { y: -6 },
    arrowWidth: 10,
    arrowHeight: 6,
    backgroundBlurStyle: BlurStyle.NONE,
    popupColor: theme.backgroundColor,
    onStateChange: (e) => {
      console.info('复杂弹窗状态变化:', e.isVisible);
    }
  };
}

function getTheme(themeType: string) {
  const themes = {
    default: { backgroundColor: '#5291FF', textColor: '#FFFFFF' },
    success: { backgroundColor: '#34C759', textColor: '#FFFFFF' },
    warning: { backgroundColor: '#FF9500', textColor: '#FFFFFF' },
    error: { backgroundColor: '#FF3B30', textColor: '#FFFFFF' }
  };
  return themes[themeType] || themes.default;
}

常见FAQ解答

Q1: 为什么要使用emitter来监听弹窗关闭事件?

A1: 使用emitter监听弹窗关闭事件有以下几个好处:

  • 解耦:弹窗的显示逻辑和关闭后的处理逻辑分离

  • 统一管理:可以在一个地方统一处理所有弹窗的关闭事件

  • 灵活性:支持多个组件监听同一个弹窗关闭事件

  • 可扩展:方便添加弹窗关闭时的其他处理逻辑(如数据清理、状态重置等)

Q2: 如何在不同页面间共享同一个弹窗配置?

A2: 可以通过以下两种方式实现共享:

  1. 工具类导出(推荐):将配置函数放在工具类中导出,任何页面都可以导入使用

// 在任何页面中
import { buildOptionsParams } from '../utils/CustomPopUtility';
  1. 全局状态管理:对于需要全局统一管理的弹窗,可以使用AppStorage或状态管理库

// 定义全局弹窗配置
AppStorage.setOrCreate('popupConfig', {
  arrowWidth: 10,
  arrowHeight: 6,
  // ... 其他配置
});

Q3: 弹窗内容需要动态变化怎么办?

A3: 可以通过参数传递动态内容,并在builder函数中处理:

// 动态内容示例
export function buildDynamicPopupOptions(
  that: Object,
  getContent: () => string,  // 函数参数,动态获取内容
  isVisible: boolean
) {
  return {
    builder: () => {
      const currentContent = getContent();  // 动态获取内容
      csPopupBuilder(currentContent, '#FFFFFF');
    },
    // ... 其他配置
  };
}

Q4: 如何处理多个弹窗同时显示的情况?

A4: HarmonyOS默认情况下,新弹窗会覆盖旧弹窗。如果需要管理多个弹窗的显示顺序,可以:

  1. 使用队列管理:创建一个弹窗队列,依次显示

  2. 优先级系统:为弹窗设置优先级,高优先级弹窗可以中断低优先级弹窗

  3. 状态标志位:使用多个状态变量分别控制不同弹窗

@Component
struct MultiPopupDemo {
  @State showPopup1: boolean = false;
  @State showPopup2: boolean = false;
  @State showPopup3: boolean = false;
  
  // 显示下一个弹窗的方法
  showNextPopup() {
    if (!this.showPopup1) {
      this.showPopup1 = true;
    } else if (!this.showPopup2) {
      this.showPopup2 = true;
    } else if (!this.showPopup3) {
      this.showPopup3 = true;
    }
  }
}

Q5: 弹窗的箭头位置和大小如何精确控制?

A5: 可以通过以下配置项精确控制弹窗箭头:

const popupOptions = {
  // 箭头大小
  arrowWidth: 10,     // 箭头底部宽度
  arrowHeight: 6,     // 箭头高度
  
  // 箭头位置偏移
  offset: { 
    dx: 0,           // 水平偏移
    dy: -6           // 垂直偏移
  },
  
  // 弹窗位置
  placement: Placement.Bottom,  // 箭头在弹窗底部
  
  // 其他影响箭头显示的配置
  popupColor: '#5291FF',        // 弹窗背景色,包括箭头
  enableArrow: true,           // 是否显示箭头
};

总结与最佳实践

封装bindPopupPopupOptions不仅能提高代码复用性,还能带来以下好处:

1. 统一设计规范

通过封装确保整个应用中的弹窗样式一致,提供统一的用户体验。

2. 提高开发效率

开发新页面时无需重新编写弹窗配置,直接调用封装好的函数即可。

3. 便于维护和修改

修改弹窗样式只需在工具类中修改一处,所有使用该弹窗的地方都会自动更新。

4. 降低出错概率

封装后的配置函数经过充分测试,避免了手动配置时可能出现的错误。

5. 最佳实践建议

  • 早期规划:在项目初期就规划好弹窗的封装方案

  • 主题系统:建立完整的弹窗主题系统,支持白天/黑夜模式

  • 文档完善:为封装的弹窗工具编写详细的使用文档

  • 测试覆盖:为封装的弹窗函数编写单元测试

  • 性能考虑:避免在频繁触发的回调中创建复杂的弹窗内容

6. 扩展思路

封装的弹窗系统还可以进一步扩展:

// 扩展方向示例
export class PopupManager {
  // 1. 支持动画效果
  static withAnimation(options: any, animationType: string) { ... }
  
  // 2. 支持国际化
  static withI18n(options: any, i18nKey: string) { ... }
  
  // 3. 支持无障碍访问
  static withAccessibility(options: any, a11yConfig: any) { ... }
  
  // 4. 支持响应式设计
  static withResponsive(options: any, breakpoints: any) { ... }
}

通过系统性的封装,我们不仅能解决代码重复的问题,更能构建出健壮、可维护、用户体验一致的弹窗系统。记住,好的封装不仅是技术的提升,更是对团队协作效率和项目可维护性的长期投资。

Logo

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

更多推荐