HarmonyOS开发:兼容性测试——多设备测试

举报
Jack20 发表于 2026/06/24 15:49:04 2026/06/24
【摘要】 HarmonyOS开发:兼容性测试——多设备测试📌 核心要点:兼容性测试确保App在不同屏幕尺寸、分辨率、折叠形态下UI表现一致,核心是响应式布局验证和折叠屏适配,自动化测试覆盖关键场景,手动测试兜底边缘情况。 背景与动机你的App在手机上完美,在平板上呢?列表项被截断了,按钮挤到一起了,弹窗只显示了一半。HarmonyOS的设备生态太丰富了——手机、平板、折叠屏、智慧屏、车机,屏幕尺寸...

HarmonyOS开发:兼容性测试——多设备测试

📌 核心要点:兼容性测试确保App在不同屏幕尺寸、分辨率、折叠形态下UI表现一致,核心是响应式布局验证和折叠屏适配,自动化测试覆盖关键场景,手动测试兜底边缘情况。

背景与动机

你的App在手机上完美,在平板上呢?列表项被截断了,按钮挤到一起了,弹窗只显示了一半。

HarmonyOS的设备生态太丰富了——手机、平板、折叠屏、智慧屏、车机,屏幕尺寸从4英寸到86英寸都有。同一个App要跑在这么多设备上,UI适配工作量巨大。

更麻烦的是折叠屏。展开和折叠两种形态,屏幕尺寸瞬间变化,你的布局能跟着变吗?折叠时正在看的页面,展开后还在吗?分屏模式下两个App同时显示,你的UI会乱吗?

手动在每台设备上测一遍?设备买全了都要好几万,更别说测试时间了。所以你需要自动化兼容性测试——用脚本在不同设备(或不同模拟器配置)上跑同一套测试,自动发现适配问题。

核心原理

兼容性测试的维度

graph TB
    A[兼容性测试] --> B[屏幕尺寸]
    A --> C[分辨率]
    A --> D[折叠形态]
    A --> E[横竖屏]
    A --> F[特殊比例]
    
    B --> B1["手机: 6-7英寸"]
    B --> B2["平板: 10-12英寸"]
    B --> B3["折叠屏: 6.5/8英寸"]
    
    C --> C1["FHD: 1080×2340"]
    C --> C2["2K: 1440×3200"]
    C --> C3["平板: 1600×2560"]
    
    D --> D1["折叠态: 窄屏"]
    D --> D2["展开态: 宽屏"]
    D --> D3["折叠中: 动态切换"]
    
    E --> E1["竖屏: 默认"]
    E --> E2["横屏: 视频场景"]
    E --> E3["自由旋转"]
    
    F --> F1["长屏: 折叠屏外屏"]
    F --> F2["方屏: 智慧屏"]
    
    classDef root fill:#4A90D9,stroke:#2C5F8A,color:#fff
    classDef size fill:#67C23A,stroke:#3E9B2B,color:#fff
    classDef res fill:#E6A23C,stroke:#B07D2B,color:#fff
    classDef fold fill:#F56C6C,stroke:#C94A4A,color:#fff
    classDef orient fill:#9B59B6,stroke:#7D3C98,color:#fff
    classDef special fill:#909399,stroke:#606266,color:#fff
    
    class A root
    class B,B1,B2,B3 size
    class C,C1,C2,C3 res
    class D,D1,D2,D3 fold
    class E,E1,E2,E3 orient
    class F,F1,F2 special

折叠屏适配测试流程

折叠屏是兼容性测试中最复杂的场景,因为涉及运行时的屏幕尺寸变化:

flowchart TD
    A[折叠态运行App] --> B[验证窄屏布局]
    B --> C[展开折叠屏]
    C --> D{App响应折叠事件?}
    D -->|| E[验证宽屏布局]
    D -->|| F[App可能重启]
    F --> G[验证重启后状态恢复]
    E --> H[折叠回去]
    G --> H
    H --> I[验证恢复窄屏布局]
    I --> J[验证数据/状态未丢失]
    
    classDef start fill:#4A90D9,stroke:#2C5F8A,color:#fff
    classDef process fill:#67C23A,stroke:#3E9B2B,color:#fff
    classDef decision fill:#E6A23C,stroke:#B07D2B,color:#fff
    classDef check fill:#9B59B6,stroke:#7D3C98,color:#fff
    
    class A start
    class B,E,H,I,J process
    class C,F process
    class D decision
    class G check

代码实战

基础用法:屏幕尺寸验证

// 467_compatibility_test_basic.ets
import { Driver, ON } from '@ohos.UiTest';
import { display } from '@kit.ArkUI';

export default function basicCompatibilityTest() {
  const driver = Driver.create();
  
  // ===== 1. 获取当前设备屏幕信息 =====
  const displayInfo = display.getDefaultDisplaySync();
  console.info(`屏幕尺寸: ${displayInfo.width}×${displayInfo.height}`);
  console.info(`屏幕密度: ${displayInfo.densityDPI} dpi`);
  console.info(`刷新率: ${displayInfo.refreshRate} Hz`);
  
  // ===== 2. 验证关键组件在不同尺寸下的可见性 =====
  // 不管屏幕多大,核心功能组件都应该可见
  
  // 搜索框应该可见
  const searchBox = driver.findComponent(ON.id('search_box'));
  if (searchBox === null) {
    console.error(`❌ [${displayInfo.width}×${displayInfo.height}] 搜索框不可见`);
  } else {
    console.info(`✅ [${displayInfo.width}×${displayInfo.height}] 搜索框可见`);
  }
  
  // 底部导航栏应该可见
  const tabBar = driver.findComponent(ON.id('tab_bar'));
  if (tabBar === null) {
    console.error(`❌ [${displayInfo.width}×${displayInfo.height}] 底部导航不可见`);
  } else {
    // 验证Tab数量
    const tabs = tabBar.findComponents(ON.type('TabContent'));
    console.info(`✅ [${displayInfo.width}×${displayInfo.height}] 底部导航有${tabs.length}个Tab`);
  }
  
  // ===== 3. 验证组件不超出屏幕边界 =====
  const submitBtn = driver.findComponent(ON.id('submit_btn'));
  if (submitBtn !== null) {
    const bounds = submitBtn.getBounds();
    const isWithinScreen = 
      bounds.left >= 0 && 
      bounds.top >= 0 && 
      bounds.right <= displayInfo.width && 
      bounds.bottom <= displayInfo.height;
    
    if (!isWithinScreen) {
      console.error(`❌ 提交按钮超出屏幕边界: ${JSON.stringify(bounds)}`);
    } else {
      console.info('✅ 提交按钮在屏幕范围内');
    }
  }
  
  // ===== 4. 验证文字不截断 =====
  const titleText = driver.findComponent(ON.id('product_title'));
  if (titleText !== null) {
    const textBounds = titleText.getBounds();
    const parentBounds = titleText.getParent()?.getBounds();
    
    if (parentBounds !== null) {
      // 检查文字是否被父容器截断
      const isTruncated = textBounds.width > parentBounds.width;
      if (isTruncated) {
        console.error('❌ 商品标题被截断');
      } else {
        console.info('✅ 商品标题完整显示');
      }
    }
  }
}

进阶用法:折叠屏与横竖屏测试

// 467_compatibility_test_advanced.ets
import { Driver, ON } from '@ohos.UiTest';
import { display } from '@kit.ArkUI';

// 折叠屏测试工具
class FoldableTestHelper {
  private driver: Driver;
  
  constructor(driver: Driver) {
    this.driver = driver;
  }
  
  // 获取当前折叠状态
  getFoldStatus(): string {
    const displayInfo = display.getDefaultDisplaySync();
    // 根据屏幕宽度判断折叠状态
    // 折叠态通常宽度 < 400vp,展开态 > 400vp
    if (displayInfo.width < 400) {
      return 'FOLDED';
    } else {
      return 'UNFOLDED';
    }
  }
  
  // 测试折叠→展开的布局适配
  async testFoldToUnfold(): Promise<FoldTestResult> {
    const result: FoldTestResult = {
      initialLayout: '',
      finalLayout: '',
      statePreserved: false,
      layoutAdapted: false,
      errors: [],
    };
    
    // 第1步:记录折叠态的UI状态
    result.initialLayout = this.getFoldStatus();
    const foldedTitle = this.getComponentText(ON.id('page_title'));
    const foldedScrollPos = this.getScrollPosition(ON.id('main_list'));
    
    // 第2步:模拟展开(通过改变窗口尺寸)
    // 在测试环境中,可以通过设置窗口尺寸模拟折叠屏展开
    await this.simulateUnfold();
    
    // 第3步:验证展开后的布局
    result.finalLayout = this.getFoldStatus();
    
    // 验证页面标题还在
    const unfoldedTitle = this.getComponentText(ON.id('page_title'));
    if (unfoldedTitle !== foldedTitle) {
      result.errors.push(`页面标题变化: "${foldedTitle}" → "${unfoldedTitle}"`);
    }
    
    // 验证滚动位置是否保持
    const unfoldedScrollPos = this.getScrollPosition(ON.id('main_list'));
    result.statePreserved = Math.abs(unfoldedScrollPos - foldedScrollPos) < 50;
    
    // 验证布局是否适配宽屏
    result.layoutAdapted = this.verifyWideScreenLayout();
    
    return result;
  }
  
  // 测试展开→折叠的布局适配
  async testUnfoldToFold(): Promise<FoldTestResult> {
    const result: FoldTestResult = {
      initialLayout: '',
      finalLayout: '',
      statePreserved: false,
      layoutAdapted: false,
      errors: [],
    };
    
    // 先确保在展开态
    await this.simulateUnfold();
    result.initialLayout = 'UNFOLDED';
    
    const unfoldedTitle = this.getComponentText(ON.id('page_title'));
    
    // 折叠
    await this.simulateFold();
    result.finalLayout = 'FOLDED';
    
    // 验证标题
    const foldedTitle = this.getComponentText(ON.id('page_title'));
    if (foldedTitle !== unfoldedTitle) {
      result.errors.push(`页面标题变化: "${unfoldedTitle}" → "${foldedTitle}"`);
    }
    
    // 验证窄屏布局
    result.layoutAdapted = this.verifyNarrowScreenLayout();
    
    return result;
  }
  
  // 验证宽屏布局
  private verifyWideScreenLayout(): boolean {
    const displayInfo = display.getDefaultDisplaySync();
    let passed = true;
    
    // 宽屏下应该显示侧边栏或双列布局
    const sideNav = this.driver.findComponent(ON.id('side_navigation'));
    if (sideNav === null) {
      // 没有侧边栏也行,但列表应该是多列
      const list = this.driver.findComponent(ON.id('product_list'));
      if (list !== null) {
        const listBounds = list.getBounds();
        // 宽屏下列表宽度应该超过屏幕60%
        if (listBounds.width < displayInfo.width * 0.6) {
          passed = false;
        }
      }
    }
    
    return passed;
  }
  
  // 验证窄屏布局
  private verifyNarrowScreenLayout(): boolean {
    let passed = true;
    
    // 窄屏下不应该有侧边栏
    const sideNav = this.driver.findComponent(ON.id('side_navigation'));
    if (sideNav !== null) {
      const navBounds = sideNav.getBounds();
      // 侧边栏在窄屏下应该隐藏或折叠
      if (navBounds.width > 0) {
        passed = false;
      }
    }
    
    // 底部导航应该可见
    const bottomNav = this.driver.findComponent(ON.id('tab_bar'));
    if (bottomNav === null) {
      passed = false;
    }
    
    return passed;
  }
  
  // 模拟展开
  private async simulateUnfold(): Promise<void> {
    // 在测试环境中通过API模拟折叠屏展开
    // 实际实现取决于测试框架的设备模拟能力
    await this.sleep(1000); // 等待布局重排
  }
  
  // 模拟折叠
  private async simulateFold(): Promise<void> {
    await this.sleep(1000);
  }
  
  // 辅助方法
  private getComponentText(selector: ON): string {
    const component = this.driver.findComponent(selector);
    return component?.getText() ?? '';
  }
  
  private getScrollPosition(selector: ON): number {
    const component = this.driver.findComponent(selector);
    if (component === null) return 0;
    const bounds = component.getBounds();
    return bounds.top;
  }
  
  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

interface FoldTestResult {
  initialLayout: string;
  finalLayout: string;
  statePreserved: boolean;
  layoutAdapted: boolean;
  errors: string[];
}

完整示例:多设备兼容性测试套件

// 467_compatibility_test_full.ets
import { Driver, ON } from '@ohos.UiTest';
import { display } from '@kit.ArkUI';

// 设备配置
interface DeviceProfile {
  name: string;
  width: number;
  height: number;
  density: number;
  type: 'phone' | 'tablet' | 'foldable';
}

const DEVICE_PROFILES: DeviceProfile[] = [
  { name: '标准手机', width: 1080, height: 2340, density: 480, type: 'phone' },
  { name: '大屏手机', width: 1440, height: 3200, density: 560, type: 'phone' },
  { name: '折叠屏(折叠)', width: 1080, height: 2340, density: 480, type: 'foldable' },
  { name: '折叠屏(展开)', width: 2208, height: 1840, density: 480, type: 'foldable' },
  { name: '平板', width: 1600, height: 2560, density: 320, type: 'tablet' },
];

// 兼容性测试套件
class CompatibilityTestSuite {
  private driver: Driver;
  private results: Map<string, CompatibilityResult>;
  
  constructor() {
    this.driver = Driver.create();
    this.results = new Map();
  }
  
  // 在当前设备上运行测试
  async testOnCurrentDevice(): Promise<void> {
    const displayInfo = display.getDefaultDisplaySync();
    const deviceName = this.identifyDevice(displayInfo);
    
    console.info(`\n📱 在 ${deviceName} (${displayInfo.width}×${displayInfo.height}) 上运行兼容性测试`);
    
    const result: CompatibilityResult = {
      deviceName,
      width: displayInfo.width,
      height: displayInfo.height,
      checks: [],
    };
    
    // 检查1:关键组件可见性
    result.checks.push(this.checkComponentVisibility('搜索框', ON.id('search_box')));
    result.checks.push(this.checkComponentVisibility('底部导航', ON.id('tab_bar')));
    result.checks.push(this.checkComponentVisibility('内容区域', ON.id('main_content')));
    
    // 检查2:组件不超出屏幕
    result.checks.push(this.checkComponentWithinScreen('提交按钮', ON.id('submit_btn'), displayInfo));
    result.checks.push(this.checkComponentWithinScreen('底部导航', ON.id('tab_bar'), displayInfo));
    
    // 检查3:文字不截断
    result.checks.push(this.checkTextNotTruncated('商品标题', ON.id('product_title')));
    result.checks.push(this.checkTextNotTruncated('价格标签', ON.id('price_label')));
    
    // 检查4:间距合理
    result.checks.push(this.checkReasonableSpacing('列表项间距', ON.id('product_list')));
    
    // 检查5:触摸目标大小
    result.checks.push(this.checkTouchTargetSize('按钮', ON.id('submit_btn'), 44));
    result.checks.push(this.checkTouchTargetSize('Tab', ON.id('tab_home'), 44));
    
    // 根据设备类型做额外检查
    if (displayInfo.width > 1400) {
      // 宽屏设备检查
      result.checks.push(this.checkWideScreenLayout());
    } else {
      // 窄屏设备检查
      result.checks.push(this.checkNarrowScreenLayout());
    }
    
    this.results.set(deviceName, result);
  }
  
  // 检查组件可见性
  private checkComponentVisibility(name: string, selector: ON): CheckResult {
    const component = this.driver.findComponent(selector);
    const visible = component !== null;
    return {
      name: `可见性: ${name}`,
      passed: visible,
      detail: visible ? '组件可见' : '组件不可见',
    };
  }
  
  // 检查组件在屏幕内
  private checkComponentWithinScreen(name: string, selector: ON, displayInfo: display.Display): CheckResult {
    const component = this.driver.findComponent(selector);
    if (component === null) {
      return { name: `屏幕内: ${name}`, passed: false, detail: '组件不存在' };
    }
    
    const bounds = component.getBounds();
    const within = bounds.left >= 0 && bounds.top >= 0 && 
                   bounds.right <= displayInfo.width && 
                   bounds.bottom <= displayInfo.height;
    
    return {
      name: `屏幕内: ${name}`,
      passed: within,
      detail: within ? '在屏幕内' : `超出屏幕: ${JSON.stringify(bounds)}`,
    };
  }
  
  // 检查文字不截断
  private checkTextNotTruncated(name: string, selector: ON): CheckResult {
    const component = this.driver.findComponent(selector);
    if (component === null) {
      return { name: `截断: ${name}`, passed: true, detail: '组件不存在,跳过' };
    }
    
    // 简化判断:检查组件宽度是否合理
    const bounds = component.getBounds();
    const text = component.getText();
    const estimatedWidth = text.length * 14; // 估算文字宽度
    const isTruncated = bounds.width < estimatedWidth * 0.5;
    
    return {
      name: `截断: ${name}`,
      passed: !isTruncated,
      detail: isTruncated ? '文字可能被截断' : '文字完整显示',
    };
  }
  
  // 检查间距合理
  private checkReasonableSpacing(name: string, selector: ON): CheckResult {
    const component = this.driver.findComponent(selector);
    if (component === null) {
      return { name: `间距: ${name}`, passed: true, detail: '组件不存在,跳过' };
    }
    
    const items = component.findComponents(ON.type('ListItem'));
    if (items.length < 2) {
      return { name: `间距: ${name}`, passed: true, detail: '列表项不足,跳过' };
    }
    
    // 检查相邻项之间的间距
    const bounds1 = items[0].getBounds();
    const bounds2 = items[1].getBounds();
    const gap = bounds2.top - bounds1.bottom;
    const reasonable = gap >= 4 && gap <= 32; // 间距在4-32vp之间
    
    return {
      name: `间距: ${name}`,
      passed: reasonable,
      detail: `间距: ${gap}vp ${reasonable ? '合理' : '异常'}`,
    };
  }
  
  // 检查触摸目标大小
  private checkTouchTargetSize(name: string, selector: ON, minSize: number): CheckResult {
    const component = this.driver.findComponent(selector);
    if (component === null) {
      return { name: `触摸: ${name}`, passed: false, detail: '组件不存在' };
    }
    
    const bounds = component.getBounds();
    const meetsMinWidth = bounds.width >= minSize;
    const meetsMinHeight = bounds.height >= minSize;
    
    return {
      name: `触摸: ${name}`,
      passed: meetsMinWidth && meetsMinHeight,
      detail: `尺寸: ${bounds.width}×${bounds.height}vp (最小${minSize}vp)`,
    };
  }
  
  // 宽屏布局检查
  private checkWideScreenLayout(): CheckResult {
    const sideNav = this.driver.findComponent(ON.id('side_navigation'));
    const hasSideNav = sideNav !== null;
    
    return {
      name: '宽屏布局',
      passed: true, // 宽屏布局有多种合理方案
      detail: hasSideNav ? '使用侧边导航布局' : '使用全宽布局',
    };
  }
  
  // 窄屏布局检查
  private checkNarrowScreenLayout(): CheckResult {
    const bottomNav = this.driver.findComponent(ON.id('tab_bar'));
    const hasBottomNav = bottomNav !== null;
    
    return {
      name: '窄屏布局',
      passed: hasBottomNav,
      detail: hasBottomNav ? '使用底部导航布局' : '缺少底部导航',
    };
  }
  
  // 识别设备
  private identifyDevice(displayInfo: display.Display): string {
    const width = displayInfo.width;
    if (width < 1200) return '手机';
    if (width < 1800) return '折叠屏展开';
    return '平板';
  }
  
  // 打印报告
  printReport(): void {
    console.info('\n📱 ===== 兼容性测试报告 =====');
    
    for (const [device, result] of this.results) {
      console.info(`\n📱 ${device} (${result.width}×${result.height})`);
      console.info('─'.repeat(50));
      
      for (const check of result.checks) {
        const status = check.passed ? '✅' : '❌';
        console.info(`  ${status} ${check.name}: ${check.detail}`);
      }
      
      const passed = result.checks.filter(c => c.passed).length;
      const total = result.checks.length;
      console.info(`  通过: ${passed}/${total}`);
    }
  }
}

interface CompatibilityResult {
  deviceName: string;
  width: number;
  height: number;
  checks: CheckResult[];
}

interface CheckResult {
  name: string;
  passed: boolean;
  detail: string;
}

// 执行测试
export default async function runCompatibilityTest() {
  const suite = new CompatibilityTestSuite();
  await suite.testOnCurrentDevice();
  suite.printReport();
}

踩坑与注意事项

坑1:模拟器和真机的渲染差异

模拟器上看着没问题的布局,真机上可能有问题。特别是字体渲染、圆角抗锯齿、阴影效果这些,模拟器和真机差异很大。

建议:关键兼容性测试一定要在真机上验证,模拟器只做初步筛查。

坑2:折叠屏的状态保持

折叠屏展开/折叠时,App可能被系统重启。如果你的测试假设App一直在运行,就会遇到"组件找不到"的问题——因为App重启了,之前的页面状态全丢了。

解决方案:测试折叠切换时,先检查App是否还在前台,如果被重启了需要重新导航到目标页面。

坑3:横竖屏切换的数据丢失

有些App在横竖屏切换时会重新创建Activity/Ability,导致页面数据丢失。测试时要验证切换后数据是否还在。

坑4:不同密度下的尺寸换算

vp和px的换算关系在不同密度设备上不同。1vp在480dpi设备上是3px,在320dpi设备上是2px。测试时要注意用哪个单位做判断。

// vp转px
const px = vp * displayInfo.densityPixels;
// px转vp
const vp = px / displayInfo.densityPixels;

坑5:安全区域适配

全面屏设备有刘海、圆角、底部手势条等安全区域。你的UI内容不能被这些区域遮挡。兼容性测试要验证关键内容不在安全区域外。

HarmonyOS 6适配说明

HarmonyOS 6对兼容性测试做了以下增强:

  1. 虚拟设备管理:测试框架内置虚拟设备管理,可以一键切换不同设备配置,不需要物理设备
  2. 折叠屏模拟增强:支持在模拟器上实时模拟折叠/展开操作,测试布局动态响应
  3. 多窗口测试:支持分屏、自由窗口等场景的自动化测试
  4. 安全区域检测:内置安全区域检测API,自动验证UI是否被遮挡
  5. 响应式布局验证:新增布局断点检测,验证不同断点下的布局是否正确

迁移注意:display.getDefaultDisplaySync()在API 13中被标记为deprecated,建议使用display.getAllDisplays()获取显示信息。

总结

兼容性测试的核心是"覆盖关键设备,验证关键场景"。你不需要在所有设备上测所有功能,但必须在主流设备上验证核心流程。

维度 评价
学习难度 ⭐⭐⭐ 基础验证简单,折叠屏适配需要深入理解
使用频率 ⭐⭐⭐⭐ 多设备生态下必做
重要程度 ⭐⭐⭐⭐⭐ 直接影响用户覆盖面

记住:兼容性测试不是"在所有设备上跑一遍",而是"确保核心体验在所有设备上都达标"。优先保证手机和平板的核心流程,再逐步覆盖折叠屏、智慧屏等特殊设备。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。