Electron + Vue3 构建一站式移动端测试平台第2期:Electron 主进程开发实践

发布于 8 天前  32 次阅读


时长:40-50分钟


2.1 Electron 基础架构

2.1.1 主进程与渲染进程通信

架构设计

我们的应用采用 Context Isolation(上下文隔离) 架构,确保安全性:

// electron/main.js
function createMainWindow() {
  mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, 'preload.cjs'),
      contextIsolation: true,  // 启用上下文隔离
      nodeIntegration: false    // 禁用 nodeIntegration,使用 contextBridge
    }
  })
}

关键点:

  • contextIsolation: true - 隔离渲染进程和主进程的上下文
  • nodeIntegration: false - 禁止渲染进程直接访问 Node.js API
  • ✅ 通过 preload.js 作为桥梁,安全地暴露 API

IPC Handler 设计模式

主进程注册 Handler:

// electron/main.js
import { ipcMain } from 'electron'

// 基础命令执行
ipcMain.handle('execute-command', async (event, { command, timeout = 15000, maxBuffer = 1024 * 1024 }) => {
  return new Promise(async (resolve) => {
    const { exec } = await import('child_process')
    const bashCommand = `/bin/sh -c "${command.replace(/"/g, '\"')}"`
    
    exec(bashCommand, { timeout, env, maxBuffer }, (error, stdout, stderr) => {
      resolve({
        success: !error,
        stdout,
        stderr,
        error: error?.message
      })
    })
  })
})

// 文件操作
ipcMain.handle('read-file-as-base64', async (event, { filePath }) => {
  try {
    const fs = await import('fs')
    const buffer = fs.readFileSync(filePath)
    return { success: true, data: buffer.toString('base64') }
  } catch (error) {
    return { success: false, error: error.message }
  }
})

Preload 暴露 API:

// electron/preload.cjs
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  // 基础命令执行
  executeCommand: (params) => ipcRenderer.invoke('execute-command', params),
  
  // 文件操作
  readFileAsBase64: (params) => ipcRenderer.invoke('read-file-as-base64', params),
  
  // Photo Proxy API
  photoProxy: {
    setDevice: (device) => ipcRenderer.invoke('photo-proxy:set-device', device),
    connect: () => ipcRenderer.invoke('photo-proxy:connect'),
    listAssets: (limit, offset) => ipcRenderer.invoke('photo-proxy:list-assets', limit, offset)
  },
  
  // URL Service API
  urlService: {
    setDevice: (device) => ipcRenderer.invoke('url-service:set-device', device),
    httpRequest: (options) => ipcRenderer.invoke('url-service:http-request', options)
  }
})

渲染进程调用:

// src/utils/adb.js (渲染进程)
const result = await window.electronAPI?.executeCommand({
  command: `test -f "${filePath}" && echo exists || echo missing`,
  timeout: 5000
})

安全通信机制

为什么使用 Context Isolation?

  1. 安全性:防止渲染进程直接访问 Node.js,避免 XSS 攻击
  2. 可控性:通过 preload.js 精确控制暴露的 API
  3. 类型安全:TypeScript 可以定义接口类型

最佳实践:

// ✅ 好的做法:通过 contextBridge 暴露
contextBridge.exposeInMainWorld('electronAPI', {
  executeCommand: (params) => ipcRenderer.invoke('execute-command', params)
})

// ❌ 不好的做法:直接暴露 Node.js
window.require = require  // 危险!
window.process = process  // 危险!

2.2 多平台设备检测

2.2.1 iOS 设备检测(usbmuxd)

实现原理:

// src/utils/ios.js
class IOSManager {
  async getDevices() {
    // 使用 go-ios 工具检测设备
    const result = await this.execute('list')
    
    if (result.success) {
      // 解析 JSON 输出
      const deviceList = JSON.parse(result.stdout)
      return deviceList.deviceList.map(udid => ({
        id: udid,
        status: 'device',
        platform: 'iOS'
      }))
    }
    return []
  }
}

关键点:

  • 使用 go-ios 工具(封装了 usbmuxd 协议)
  • 通过 ios list 命令获取设备列表
  • 支持多设备并行检测

2.2.2 Android 设备检测(ADB)

实现原理:

// src/utils/adb.js
class ADBManager {
  async getDevices() {
    // 使用 adb devices 命令
    const result = await this.execute('devices -l')
    
    if (result.success) {
      const lines = result.stdout.split('n')
      const devices = []
      
      for (const line of lines) {
        if (line.includes('tdevice')) {
          const parts = line.split('t')
          const id = parts[0]
          devices.push({
            id,
            status: 'device',
            platform: 'Android'
          })
        }
      }
      return devices
    }
    return []
  }
}

关键点:

  • 使用 adb devices -l 获取设备列表
  • 解析输出格式:设备IDt状态
  • 支持通过 -s 参数指定设备

2.2.3 Harmony 设备检测(HDC)

实现原理:

// src/utils/hdc.js
class HDCManager {
  async getDevices() {
    // 使用 hdc list targets 命令
    const result = await this.execute('list targets')
    
    if (result.success) {
      const lines = result.stdout.split('n')
      const devices = []
      
      for (const line of lines) {
        if (line.includes('device')) {
          const parts = line.split(/s+/)
          devices.push({
            id: parts[0],
            status: 'device',
            platform: 'Harmony'
          })
        }
      }
      return devices
    }
    return []
  }
}

2.2.4 设备状态实时监控

统一设备服务:

// src/utils/deviceService.js
class DeviceService {
  async getDeviceStatus() {
    const timeout = 8000
    
    // 并行检测所有平台
    const [
      androidAvailable,
      harmonyAvailable,
      iosAvailable,
      androidDevices,
      harmonyDevices,
      iosDevices
    ] = await Promise.all([
      withTimeout(this.adb.isAvailable(), timeout),
      withTimeout(this.hdc.isAvailable(), timeout),
      withTimeout(this.ios.isAvailable(), timeout),
      withTimeout(this.adb.getDevices(), timeout),
      withTimeout(this.hdc.getDevices(), timeout),
      withTimeout(this.ios.getDevices(), timeout)
    ])
    
    return {
      android: {
        available: !!androidAvailable,
        devices: androidDevices || [],
        hasDevices: (androidDevices || []).length > 0
      },
      harmony: { / ... / },
      ios: { / ... / }
    }
  }
}

优化策略:

  • ✅ 并行检测,提高响应速度
  • ✅ 超时保护,避免长时间阻塞
  • ✅ 容错处理,单个平台失败不影响其他平台

2.3 服务启动与管理

2.3.1 Photo Proxy 服务启动

服务初始化:

// WebDriverAgent/photo-proxy/ipc-handler.ts
export function initPhotoProxyIPC(): void {
  // 创建 PhotosService 实例
  photosService = new PhotosService()
  
  // 注册 IPC Handlers
  ipcMain.handle('photo-proxy:set-device', async (event, device: IOSDevice) => {
    photosService!.setDevice(device)
    return { success: true }
  })
  
  ipcMain.handle('photo-proxy:connect', async () => {
    const connected = await photosService!.connect()
    return { success: connected }
  })
  
  ipcMain.handle('photo-proxy:list-assets', async (event, limit, offset) => {
    return await photosService!.listAssets(limit, offset)
  })
}

服务连接流程:

// WebDriverAgent/photo-proxy/usb/service.ts
class PhotosService {
  async connect(): Promise {
    // 1. 检查 Developer Disk Image 是否已挂载
    await this.ensureImageMounted()
    
    // 2. 建立端口转发(12345)
    await this.bridgeService.setupPortForward()
    
    // 3. 连接到 Companion Service
    await this.bridgeService.connect()
    
    return this.bridgeService.connected
  }
}

2.3.2 URL Service 服务管理

HTTP 请求代理:

// WebDriverAgent/photo-proxy/usb/url-service.ts
class URLService {
  async httpRequest(options: {
    url: string
    method?: string
    headers?: Record
    body?: string
    timeoutMs?: number
    skipWDA?: boolean
  }): Promise {
    // 1. 快速检查 WDA 是否可用(1秒超时)
    if (!options.skipWDA) {
      try {
        const statusResponse = await fetch('http://127.0.0.1:8100/status', {
          signal: AbortSignal.timeout(1000)
        })
        if (statusResponse.ok) {
          // 使用 WDA HTTP API
          return await this.requestViaWDA(options)
        }
      } catch (error) {
        // WDA 不可用,回退到 Companion Service
      }
    }
    
    // 2. 回退到 Companion Service
    if (!this.connected) {
      await this.connect()
    }
    return this.bridgeService!.httpRequest(options)
  }
}

关键优化:

  • ✅ 快速失败机制(1秒超时)
  • ✅ 自动回退到 Companion Service
  • ✅ 支持跳过 WDA 直接使用 Companion

2.3.3 端口转发与资源管理

端口转发实现:

// WebDriverAgent/photo-proxy/protocol/bridge-port-forward.ts
class BridgeServicePortForward {
  private async setupPortForward(udid: string): Promise {
    // 1. 清理旧进程
    await this.cleanupExistingPortForward()
    
    // 2. 启动端口转发
    const forwardProcess = spawn(iosPath, [
      'forward',
      '12345',
      '12345',
      `--udid=${udid}`
    ])
    
    // 3. 等待转发建立
    await new Promise(resolve => setTimeout(resolve, 1500))
    
    // 4. 连接到本地端口
    await this.connectToLocalPort()
  }
}

资源清理:

private async cleanupExistingPortForward(): Promise {
  // 查找现有进程
  const psCmd = `ps aux | grep -E '[i]os.forward.12345' | grep -v grep`
  const result = await execAsync(psCmd)
  
  if (result.stdout) {
    const processes = result.stdout.split('n').filter(line => line.trim())
    for (const line of processes) {
      const pid = line.split(/s+/)[1]
      if (pid) {
        process.kill(parseInt(pid), 'SIGTERM')
      }
    }
  }
}

2.3.4 错误处理与重试机制

超时与重试:

// src/utils/ios.js
async ensureWdaInstalled(deviceId = '') {
  // 防抖:按设备并发保护
  const key = deviceId || '_default'
  if (this._ensureWdaInstallingMap.has(key)) {
    // 等待其他安装完成(最多 10 秒)
    for (let i = 0; i  setTimeout(r, 500))
      const check = await this.isWdaInstalled(deviceId)
      if (check) return { success: true, already: true }
    }
  }
  
  // 检查是否已安装
  const check = await this.isWdaInstalled(deviceId)
  if (check.installed) {
    return { success: true, already: true }
  }
  
  // 执行安装
  this._ensureWdaInstallingMap.add(key)
  try {
    const installRes = await this.execute(`install --path="${ipaPath}"`, { timeout: 120000 })
    // ...
  } finally {
    this._ensureWdaInstallingMap.delete(key)
  }
}

错误分类处理:

// WebDriverAgent/photo-proxy/usb/url-service.ts
catch (wdaError: any) {
  const errorMsg = wdaError.message || String(wdaError)
  
  // 连接错误:回退到 Companion Service
  if (errorMsg.includes('fetch failed') || 
      errorMsg.includes('ECONNREFUSED') || 
      errorMsg.includes('not available')) {
    console.warn('WDA 不可用,回退到 Companion Service')
    return this.bridgeService!.httpRequest(options)
  }
  
  // 其他错误:直接抛出
  throw wdaError
}

2.4 打包与部署

2.4.1 Electron Builder 配置

基础配置:

// package.json
{
  "build": {
    "appId": "com.xiaoying.cardloan.tool",
    "productName": "小赢MobileTool",
    "directories": {
      "output": "release",
      "buildResources": "build"
    },
    "files": [
      "dist/**",
      "electron/**",
      "package.json",
      "assets/icons/**",
      "WebDriverAgent/photo-proxy/*/.js"
    ],
    "extraResources": [
      {
        "from": "commands",
        "to": "commands",
        "filter": ["*/"]
      }
    ]
  }
}

关键配置说明:

  • files: 包含在应用包中的文件
  • extraResources: 作为资源文件(不打包进 asar),可通过 process.resourcesPath 访问

2.4.2 多平台打包策略

Mac 平台配置:

{
  "mac": {
    "category": "public.app-category.developer-tools",
    "icon": "assets/icons/icon-512.png",
    "target": [
      {
        "target": "dmg",
        "arch": ["x64", "arm64"]
      }
    ],
    "identity": null  // 使用临时签名
  }
}

Windows 平台配置:

{
  "win": {
    "icon": "assets/icons/icon.ico",
    "target": [
      {
        "target": "nsis",
        "arch": ["x64", "arm64"]
      }
    ]
  }
}

2.4.3 资源文件管理

命令工具路径检测:

// src/utils/commandDetector.js
class CommandDetector {
  static async getAppPath() {
    if (window.electronAPI) {
      const appPath = await window.electronAPI.getAppPath()
      
      // 打包后:appPath 指向 .app/Contents/Resources/app.asar
      // 资源文件在:appPath/../commands/
      if (appPath.includes('.asar')) {
        return appPath.replace('app.asar', 'app.asar.unpacked')
      }
      
      // 开发环境:直接返回
      return appPath
    }
    return process.cwd()
  }
  
  static async getCommandPath(commandName) {
    const appPath = await this.getAppPath()
    const commandPath = `${appPath}/commands/${commandName}`
    
    // 检查文件是否存在
    const check = await window.electronAPI?.executeCommand({
      command: `test -f "${commandPath}" && echo exists || echo missing`
    })
    
    if (check?.stdout?.includes('exists')) {
      return commandPath
    }
    
    // 回退到系统 PATH
    return commandName
  }
}

关键点:

  • ✅ 开发环境:使用项目根目录的 commands/
  • ✅ 打包后:使用 app.asar.unpacked/commands/
  • ✅ 自动回退到系统 PATH

2.4.4 签名与分发

签名配置:

// build/afterSign.cjs
exports.default = async function(context) {
  // 自动签名后的处理
  console.log('签名完成:', context.packager.platform, context.packager.arch)
}

打包脚本:

{
  "scripts": {
    "dist": "yarn build:photo-proxy && yarn build && electron-builder --mac --x64 --arm64",
    "dist:mac": "electron-builder --mac dmg --x64 --arm64"
  }
}

打包流程:

  1. 编译 TypeScriptyarn build:photo-proxy
  2. 构建前端yarn build
  3. 打包 Electronelectron-builder --mac --x64 --arm64

2.5 实际案例:iOS 设备环境信息获取

2.5.1 完整流程

// 1. 渲染进程发起请求
const result = await DeviceService.ios.fetchUrlOnDevice(url, deviceId)

// 2. 通过 IPC 调用主进程
// src/utils/ios.js
async fetchUrlOnDevice(url, deviceId) {
  const resp = await window.electronAPI.urlService.httpRequest({
    url,
    method: 'GET',
    headers: { 'Cache-Control': 'no-cache' },
    timeoutMs: 10000
  })
  
  return {
    success: resp.success,
    data: resp.data
  }
}

// 3. 主进程处理请求
// electron/main.js
ipcMain.handle('url-service:http-request', async (event, options) => {
  // 初始化 URLService(懒加载)
  if (!urlServiceInstance) {
    await initURLServiceIPC()
  }
  
  // 调用 URLService
  return await urlServiceInstance.httpRequest(options)
})

// 4. URLService 选择执行路径
// WebDriverAgent/photo-proxy/usb/url-service.ts
async httpRequest(options) {
  // 快速检查 WDA(1秒超时)
  try {
    await fetch('http://127.0.0.1:8100/status', { signal: AbortSignal.timeout(1000) })
    // 使用 WDA
  } catch {
    // 回退到 Companion Service
    await this.connect()
    return this.bridgeService!.httpRequest(options)
  }
}

// 5. Companion Service 在设备上执行请求
// WebDriverAgent/photo-proxy/protocol/bridge-port-forward.ts
async httpRequest(options) {
  // 通过端口转发发送请求到设备
  const response = await this.sendRequest(FrameType.HTTP_REQUEST, payload)
  
  // 解析响应(支持 HTML 格式)
  if (response.body?.includes('<pre>')) {
    const jsonMatch = response.body.match(/<pre>]&gt;([sS]?)/i)
    if (jsonMatch) {
      return JSON.parse(jsonMatch[1])
    }
  }
  
  return response
}

2.5.2 关键优化点

  1. 快速失败:WDA 检查 1 秒超时,立即回退
  2. 自动回退:WDA 失败自动使用 Companion Service
  3. 格式兼容:支持 JSON 和 HTML
    
    

    包裹的 JSON

  4. 错误处理:完善的错误分类和重试机制

2.6 总结

核心要点

  1. 安全性优先:使用 Context Isolation,通过 preload.js 安全暴露 API
  2. 并行检测:多平台设备检测并行执行,提高响应速度
  3. 服务管理:Photo Proxy 和 URL Service 的懒加载和生命周期管理
  4. 资源管理:端口转发进程的清理和复用
  5. 错误处理:完善的超时、重试和回退机制

最佳实践

  • ✅ 使用 contextBridge 而不是直接暴露 Node.js API
  • ✅ IPC Handler 统一返回 { success, data, error } 格式
  • ✅ 资源文件放在 extraResources,不打包进 asar
  • ✅ 命令工具路径检测支持开发和生产环境
  • ✅ 服务启动采用懒加载,按需初始化

附录:代码结构

mobile-tools-new/
├── electron/
│   ├── main.js              # 主进程入口
│   ├── preload.cjs          # Preload 脚本
│   └── ipc-handler.ts        # IPC Handler 定义
├── src/
│   └── utils/
│       ├── adb.js           # Android 设备管理
│       ├── hdc.js           # Harmony 设备管理
│       ├── ios.js            # iOS 设备管理
│       └── deviceService.js # 统一设备服务
├── WebDriverAgent/
│   └── photo-proxy/
│       ├── usb/
│       │   ├── service.ts   # PhotosService
│       │   └── url-service.ts # URLService
│       └── protocol/
│           └── bridge-port-forward.ts # 端口转发服务
└── commands/                 # 命令工具(extraResources)
    ├── adb
    ├── ios
    ├── hdc
    └── wda.ipa / lower-wda.ipa

一名测试工作者,专注接口测试、自动化测试、性能测试、Python技术。