Home
Perfecto的头像

Perfecto

React Native + Detox:Android 模拟器运行测试排查

AI 速览

这篇文章讲述了从 iOS 切换到 Android 模拟器时遇到的一个典型排查误区。App 启动后卡在 Expo Dev Client 页面,adb logcat 中充斥着 libappmodules.so 加载失败的错误日志,很容易让人误以为是 native 模块编译问题。但这其实是一个误导信息——这些错误在 Debug 模式下本就存在且无害。真正的问题藏在 Android 模拟器的网络隔离机制中:模拟器运行在独立的 QEMU 虚拟网络中,无法直接访问宿主机的 localhost:8081。解决方案是使用 adb reverse 进行端口转发。文章的核心教训是:排查时要区分”噪音日志”和”关键日志”,不要被非致命错误带偏方向


系列文章导航


环境与目标

组件
版本
React Native
0.74.5 (Expo SDK 51)
Detox
20.42.0
expo-dev-client
~4.0.26
Android Emulator
Pixel 7 API 35 (AOSP)
Android Studio
Ladybug Feature Drop 2024.2

目标:在本地 Android AOSP 模拟器上成功运行 E2E 测试。

测试策略:

  • 本地开发:iOS 模拟器(开发体验好)
  • 本地验证:Android 模拟器(覆盖双平台)
  • 云端 CI:BrowserStack Android(成本考量)

问题概述

完成 iOS E2E 测试配置后,切换到 Android 模拟器运行测试,App 启动后卡在 Expo Dev Client 的 “Development servers” 页面,Metro 终端无任何 Bundle 日志。

与 iOS 的现象类似,但根因完全不同。


问题 1:libappmodules.so 加载失败

现象

使用 adb logcat 排查时,发现大量 native 加载错误:

E linker  : library "/data/app/~~xxx/lib/arm64/libappmodules.so" not found
E ReactNativeJNI: Error loading libappmodules.so

排查过程

  1. 误判方向:以为是 native 模块编译问题
  2. 检查 android/app/build.gradlendk.abiFilters
  3. 检查 Hermes 引擎配置
  4. 尝试 ./gradlew clean 重新构建

结论

这是一个误导信息。libappmodules.so 在 Debug 模式下本就不存在,这些日志是正常的。

关键教训:排查时要区分”噪音日志”和”关键日志”,不要被非致命错误带偏方向。


问题 2:模拟器存储空间不足

现象

构建完成后安装 APK 失败:

INSTALL_FAILED_INSUFFICIENT_STORAGE

原因

Android 模拟器默认配置:

  • Internal Storage: 2 GB(系统 + 应用)
  • SD Card: 512 MB

Debug 构建的 APK 体积较大(含完整 Hermes bytecode、Source Map),加上系统占用,2 GB 很快耗尽。

解决方案

在 Android Studio 中调整模拟器存储配置:

  1. Device Manager → 选中模拟器 → Edit
  2. Show Advanced Settings
  3. 调整存储参数:
参数
原值
建议值
Internal Storage
2048
8192
SD Card
512
2048
  1. Wipe Data 重置模拟器(必须)

注意:修改存储配置后必须 Wipe Data,否则新配置不生效。


问题 3:Android 模拟器网络隔离(核心问题)

现象

存储问题解决后,App 依然卡在 “Development servers” 页面。使用 iOS 成功的方案(Deep Link + localhost),在 Android 上完全无效。

根因分析

Android 模拟器运行在虚拟化网络环境中,与宿主机存在网络隔离。

Android Emulator 内部使用 localhost 无法访问宿主机上的 Metro Server,必须使用特殊地址 10.0.2.2 才能正确访问宿主机的网络服务。这是 Android 模拟器网络隔离机制导致的典型问题。

关键概念:

地址
在模拟器中指向
说明
127.0.0.1
模拟器自身

标准回环地址,隔离在虚拟网络中
localhost
模拟器自身
解析为 127.0.0.1
10.0.2.2
宿主机 127.0.0.1
Android 模拟器专用的宿主机别名
10.0.2.1
虚拟路由器
模拟器默认网关
10.0.2.3
DNS 服务器
模拟器使用的 DNS

这是 Android 模拟器的 QEMU 网络栈设计,10.0.2.0/24 是保留的虚拟网段。

参考文档:Android Emulator Networking

iOS vs Android 对比


问题 4:ADB Reverse 端口转发

概念说明

adb reverse 是 Android 调试桥提供的端口反向转发机制:

adb reverse tcp:8081 tcp:8081

这条命令的含义:

模拟器内部访问 localhost:8081 → 转发到 → 宿主机 localhost:8081

执行后,模拟器内的 App 访问 localhost:8081 就能到达宿主机的 Metro 服务器。

Detox 内置支持

好消息是 Detox 提供了 reversePorts 配置项,自动执行 adb reverse

// .detoxrc.js
module.exports = {
  apps: {
    'android.emu.debug': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
      reversePorts: [8081],  // 自动执行 adb reverse tcp:8081 tcp:8081
    },
  },
};

原理:Detox 在 device.launchApp() 时自动调用 adb reverse,无需手动执行。

参考文档:Detox Android Configuration - reversePorts


URL Scheme 构造

Expo Dev Client 的 Deep Link 格式:

exp+{slug}://expo-development-client/?url={metroUrl}

其中:

  • {slug} 来自 app.config.ts 中的 slug 字段
  • {metroUrl} 是 Metro 服务器地址(需 URL 编码)

Android 特殊处理

Android 版本需要使用 10.0.2.2 替代 localhost

// iOS
const metroUrl = 'http://localhost:8081';

// Android
const metroUrl = 'http://10.0.2.2:8081';

参考文档:Expo Deep Linking


问题 6:Android 必须在启动时传递 URL

iOS vs Android 启动差异

在 iOS 上,使用”两阶段启动”解决 Expo Dev Client 时序问题:

// iOS: 两阶段启动
await device.launchApp({ newInstance: true });  // 阶段一:启动
await sleep(3000);                               // 等待初始化
await device.openURL({ url: deepLinkUrl });     // 阶段二:发送 URL

但在 Android 上,device.openURL() 方法行为不同:

实测发现,Android 上分阶段发送 Deep Link 并不可靠。

解决方案

Android 必须在 launchApp 时直接传递 URL:

// Android: 一次性启动 + URL
await device.launchApp({
  newInstance: true,
  url: deepLinkUrl,  // 必须在启动时传递
  launchArgs: { ... },
});

参考文档:Detox launchApp with URL


完整解决方案

jest.setup.ts 核心配置

// ============================================
// 配置常量
// ============================================
const CONFIG = {
  METRO_PORT: 8081,
  SCHEMA: 'exp+moego-business-2',  // 来自 app.config.ts 的 slug
  LAUNCH_ARGS: {
    detoxDebugVisibility: 'YES',
    EXPO_PUBLIC_IS_E2E: 'true',
  },
  IOS_INIT_DELAY: 3000,  // iOS 两阶段启动等待时间
};

const PERMISSIONS = {
  location: 'inuse',
  notifications: 'YES',
  camera: 'YES',
  photos: 'YES',
};

// ============================================
// Deep Link 构造函数
// ============================================

/**
 * 构建 Metro Deep Link URL
 * - iOS 使用 localhost(共享宿主机网络栈)
 * - Android 使用 10.0.2.2(QEMU 虚拟网络代理地址)
 */
function buildMetroDeepLink(platform: 'ios' | 'android'): string {
  const metroHost = platform === 'ios' ? 'localhost' : '10.0.2.2';
  const metroUrl = `http://${metroHost}:${CONFIG.METRO_PORT}`;

  const searchParams = new URLSearchParams();
  searchParams.set('url', metroUrl);

  // 附加 launchArgs 到 URL
  Object.entries(CONFIG.LAUNCH_ARGS).forEach(([key, value]) => {
    searchParams.set(key, String(value));
  });

  return `${CONFIG.SCHEMA}://expo-development-client/?${searchParams.toString()}`;
}

// ============================================
// 平台特定启动逻辑
// ============================================

/**
 * iOS 启动:两阶段策略
 * 解决 Expo Dev Client + Detox 的时序问题
 */
async function launchIosDebugMode() {
  // 阶段一:启动 App,不带 URL
  await device.launchApp({
    newInstance: true,
    permissions: PERMISSIONS,
    launchArgs: CONFIG.LAUNCH_ARGS,
  });

  // 等待 Expo Dev Client 原生模块初始化
  log.info('iOS: Waiting for Expo Dev Client initialization...');
  await new Promise((resolve) => setTimeout(resolve, CONFIG.IOS_INIT_DELAY));

  // 阶段二:发送 Deep Link 触发 Metro 连接
  const deepLinkUrl = buildMetroDeepLink('ios');
  log.info('iOS: Sending Deep Link to trigger Metro connection...');
  await device.openURL({ url: deepLinkUrl });
}

/**
 * Android 启动:一次性策略
 * Android 必须在 launchApp 时传递 URL
 */
async function launchAndroidDebugMode() {
  const deepLinkUrl = buildMetroDeepLink('android');
  log.info(`Android: Launching with Deep Link: ${deepLinkUrl}`);

  await device.launchApp({
    newInstance: true,
    url: deepLinkUrl,  // Android 必须在启动时传递 URL
    permissions: PERMISSIONS,
    launchArgs: CONFIG.LAUNCH_ARGS,
  });
}

// ============================================
// 统一入口
// ============================================

beforeAll(async () => {
  const platform = device.getPlatform();
  log.info(`Platform: ${platform}`);

  if (platform === 'ios') {
    await launchIosDebugMode();
  } else {
    await launchAndroidDebugMode();
  }

  // 等待 JS Bundle 加载完成
  await waitFor(element(by.text('Log in')))
    .toBeVisible()
    .withTimeout(60000);

  log.info('App launched and ready for testing');
});

Detox 配置 (.detoxrc.js)

module.exports = {
  testRunner: {
    args: {
      $0: 'jest',
      config: 'e2e/jest.config.js',
    },
  },
  apps: {
    'ios.sim.debug': {
      type: 'ios.app',
      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/business.app',
      build: 'xcodebuild -workspace ios/moegomobile.xcworkspace -scheme business -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
    },
    'android.emu.debug': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
      build: 'cd android && ./gradlew assembleDebug',
      reversePorts: [8081],  // 关键:自动执行 adb reverse
    },
  },
  devices: {
    simulator: {
      type: 'ios.simulator',
      device: { type: 'iPhone 16' },
    },
    emulator: {
      type: 'android.emulator',
      device: { avdName: 'Pixel_7_API_35' },
    },
  },
  configurations: {
    'ios.sim.debug': {
      device: 'simulator',
      app: 'ios.sim.debug',
    },
    'android.emu.debug': {
      device: 'emulator',
      app: 'android.emu.debug',
    },
  },
};

快速排查检查清单


技术总结

Android 模拟器网络架构

核心要点

  1. 网络隔离:Android 模拟器运行在 QEMU 虚拟化环境,localhost 指向模拟器自身而非宿主机

  2. 10.0.2.2 特殊地址:这是 Android 模拟器预留的地址,自动映射到宿主机的 127.0.0.1

  3. ADB Reverse:adb reverse tcp:8081 tcp:8081 创建反向隧道,让模拟器内的 localhost:8081 转发到宿主机

  4. Detox reversePorts:配置后自动执行 adb reverse,无需手动干预

  5. 启动时序差异:

    • iOS:两阶段启动(先启动后发 URL)
    • Android:一次性启动(URL 必须在 launchApp 时传递)

参考文档

在排查过程中查阅并验证有效的官方文档:

  1. Android 模拟器网络基础 (10.0.2.2)

  2. ADB 端口转发 (reversePorts)

  3. Android 9+ 网络安全配置

  4. 模拟器存储管理

技术实践 APP开发 React Native E2E测试