多设备统一执行 App 操作流程



如何使用

简介:

本示例展示如何通过 Total Control 在多台 Android 设备上同步执行同一套 App 操作流程。脚本可自动完成应用启动、前台校验、点击、输入、翻页、返回主页及关闭应用,并在执行过程中记录步骤耗时、自动截图并保存失败现场,同时采集关键运行上下文信息,适合用于多设备自动化测试、批量操作及新手学习参考。

运行前:

  1. 安装 Total Control(专业版)
    • 下载并安装 Total Control 11.0 (Update 20)官方版本
    • 需要使用专业版(Pro),如果当前是精简版,有一次 3 天的专业版试用机会
  2. 手机安装 SigmaTestApp
    • 在每一台要执行脚本的 Android 设备中安装 SigmaTestApp
    • 默认包名: com.sigma_rt.sigmatestapp
  3. 设备连接并选中
    • 使用 USB 或网络连接设备
    • 在 Total Control 中勾选要操作的设备
    • 脚本使用 getDevices() 获取当前已选设备
  4. 将示例代码保存为 JS 文件,例如:D:/scripts/multi_device_flow.js
  5. 修改配置:
    • TARGET_PKG:目标 App 包名
    • SEARCH_TEXT:输入内容
    • SEARCH_X/Y:搜索框相对坐标(0~1)
    • LOG_DIR:日志/截图输出目录
    • WAIT_LAUNCH_MS App:启动等待时间
  6. 打开脚本终端:主面板 → 脚本 → 脚本终端
  7. 执行:
    sigmaLoad("D:/scripts/multi_device_flow.js");


源代码

/**
 * ============================================================
 * 多设备统一执行一套 App 操作流程(可直接复制运行)
 * ============================================================
 * 对每台设备执行:启动 App → 校验前台 → 点击 → 输入 → 翻页 → 返回主页 → 关闭 App
 *
 * 1) 获取设备:只用 getDevices()(来自 sigma/device)
 * 2) 每一步都打印:开始 / 结束 / 耗时(stepRun)
 * 3) 失败自动截图(sigma/image -> device.screenshot)
 * 4) 失败自动写上下文:前台包名 + activity + 电量 + 设备信息(writeFailContext)
 * 5) 代码内含“详细注释文档”,方便直接拷贝给新人学习/排错
 *
 * ------------------------------------------------------------
 * 模块职责
 * - device : 获取设备、读取设备属性
 * - app    : runApp/isAppForeground/getForegroundApp/getActivity/closeApp
 * - input  : click/click2/send(系统按键 HOME/BACK/ENTER)/scroll/swipe 等
 * - navKeys: pgUp/pgDn/up/down/shiftPgDn...(翻页/方向移动,非系统键)
 * - image  : screenshot/screenshotToMemory/seekImage 等(这里用于失败留证截图)
 * - tests  : inputText/inputForm/getClipboardText(本版本用于“输入”)
 * ------------------------------------------------------------
 *
 * 运行前提:
 * - 在 Total Control UI 中先“选中/勾选”你要操作的设备
 * - getDevices() 会返回这些“已选设备”
 *
 * 常见调参位置(新手只需改这里):
 * - TARGET_PKG     目标 App 包名
 * - SEARCH_TEXT    输入内容(教学建议先用英文/数字)
 * - SEARCH_X/Y     搜索框相对坐标(0~1)
 * - LOG_DIR        日志/截图输出目录
 * - WAIT_LAUNCH_MS App 启动等待时间
 */

// ===================== 引入与注入 =====================

var tcConst = global.tcConst || {};
var sigmaConst = global.sigmaConst || {};

// device:只用 getDevices() 获取设备(按你的要求)
var { getDevices, Device } = require("sigma/device");

// app/input/navKeys/image/tests 都会挂载到 Device.prototype
// 所以 require 后就能直接 device.runApp()/device.click2()/device.pgDn()/device.screenshot()/device.inputText() ...
require("sigma/app");
require("sigma/input");
require("sigma/navKeys");
require("sigma/image");
require("sigma/tests"); // 新增:tests 输入模块(inputText/inputForm/getClipboardText)

// ===================== 可配置区 =====================

// 目标包名(按需替换)
// var TARGET_PKG = "com.tencent.qqmusic";
var TARGET_PKG = "com.sigma_rt.sigmatestapp";

// 输入内容
var SEARCH_TEXT = "500";

// 启动等待(设备性能不同,建议 2000~6000ms)
var WAIT_LAUNCH_MS = 2500;

// 搜索框相对坐标:0~1(跨分辨率更稳定)
// var SEARCH_X = 0.5;
// var SEARCH_Y = 0.12;
var SEARCH_X = 0.5352;
var SEARCH_Y = 0.135;

// 日志/截图输出目录(按你的电脑改)
var LOG_DIR = "D:/tc_logs";

// 图片格式常量:优先 JPG(小),没有就用 PNG
// 注意:你们环境可能使用 sigmaConst.IMG_JPG 或 tcConst.IMG_JPG
var IMG_TYPE = (sigmaConst.IMG_JPG || tcConst.IMG_JPG || sigmaConst.IMG_PNG || tcConst.IMG_PNG);

// ===================== 通用工具函数 =====================

/** 返回当前毫秒时间戳(用于耗时统计) */
function nowMs() { return Date.now(); }

function sleep(ms) {
  if (typeof global.sleep === "function") return global.sleep(ms);
  var t = Date.now();
  while (Date.now() - t < ms) {}
}

/** 打印用的设备名(优先 name,其次 getName,再次 SN) */
function devName(d) {
  return d.name || (d.getName && d.getName()) || d.SN || "UnknownDevice";
}

/** 两位补零(用于时间戳格式化) */
function pad2(n) { return (n < 10 ? "0" : "") + n; }

/** 生成 YYYYMMDD_HHMMSS 时间戳(用于日志/截图文件名) */
function ts() {
  var d = new Date();
  return (
    d.getFullYear() +
    pad2(d.getMonth() + 1) +
    pad2(d.getDate()) + "_" +
    pad2(d.getHours()) +
    pad2(d.getMinutes()) +
    pad2(d.getSeconds())
  );
}

/** 把字符串转成安全文件名片段(去掉非法字符) */
function safeFilePart(s) {
  return String(s).replace(/[^\w\-.]+/g, "_");
}

// ===================== 文件写入:有则落盘,无则退化仅控制台 =====================
var fs = null;
try { fs = require("fs"); } catch (e) { fs = null; }

/** 尝试创建目录(支持则创建,不支持则返回 false) */
function ensureDir(path) {
  if (!fs) return false;
  try {
    if (!fs.existsSync(path)) fs.mkdirSync(path, { recursive: true });
    return true;
  } catch (e) {
    return false;
  }
}

/** 追加写文件(支持则写入,不支持则返回 false) */
function appendFile(path, text) {
  if (!fs) return false;
  try {
    fs.appendFileSync(path, text, { encoding: "utf8" });
    return true;
  } catch (e) {
    return false;
  }
}

// ===================== 关键:步骤执行器(开始/结束/耗时) =====================

// 目的:让业务步骤统一输出格式;任何步骤异常都能精准定位。
// 使用方式:stepRun(ctx, "StepName", function(){ ... })

function stepRun(ctx, stepName, fn) {
  var start = nowMs();
  ctx.log(">> START  " + stepName);

  try {
    var ret = fn();
    var cost = nowMs() - start;
    ctx.log("<< END    " + stepName + "  (cost " + cost + " ms)");
    return { ok: true, ret: ret, cost: cost };
  } catch (e) {
    var cost2 = nowMs() - start;
    ctx.log("<< FAIL   " + stepName + "  (cost " + cost2 + " ms)  err=" + String(e && e.message ? e.message : e));
    return { ok: false, err: String(e && e.message ? e.message : e), cost: cost2 };
  }
}

// ===================== input:系统键 =====================

/**
 * press:发送系统按键(HOME/BACK/ENTER 等)
 * - input.send(code, state) -> 成功返回 0,失败返回 -1(用 lastError() 查看原因)
 */
function press(d, keyConst) {
  var ret = d.send(keyConst, tcConst.STATE_PRESS);
  if (ret !== 0) throw new Error("send failed: " + lastError());
}

// ===================== 失败处理:上下文采集 + 截图 =====================

/** 安全取值:防止某些属性/方法在个别设备上报错导致二次失败 */
function safeGet(fn, fallback) {
  try { return fn(); } catch (e) { return fallback; }
}

/**
 * 失败上下文写入日志:
 * - foregroundApp / activity
 * - battery
 * - manufacturer/model/SN/IP/DPI/resolution/androidVersion...
 */
function writeFailContext(ctx, d, stepName, errText) {
  ctx.log("---- FAIL CONTEXT BEGIN ----");
  ctx.log("step=" + stepName);
  ctx.log("error=" + (errText || ""));

  // 前台包名/Activity(app 模块方法,注入到 device)
  var fg = safeGet(function () { return d.getForegroundApp(); }, null);
  var act = safeGet(function () { return d.getActivity(); }, null);
  ctx.log("foregroundApp=" + (fg || ""));
  ctx.log("activity=" + (act || ""));

  // 电量(device 属性)
  var battery = safeGet(function () { return d.battery; }, null);
  ctx.log("battery=" + (battery !== null && battery !== undefined ? battery : ""));

  // 设备基础信息(device 属性)
  ctx.log("name=" + safeGet(function () { return d.name; }, ""));
  ctx.log("manufacturer=" + safeGet(function () { return d.manufacturer; }, ""));
  ctx.log("model=" + safeGet(function () { return d.model; }, ""));
  ctx.log("SN=" + safeGet(function () { return d.SN; }, ""));
  ctx.log("IP=" + safeGet(function () { return d.IP; }, ""));
  ctx.log("DPI=" + safeGet(function () { return d.DPI; }, ""));
  ctx.log("resolution=" + safeGet(function () { return d.width; }, "") + "x" + safeGet(function () { return d.height; }, ""));
  ctx.log("androidVersionRelease=" + safeGet(function () { return d.androidVersionRelease; }, ""));
  ctx.log("androidVersionSdkInt=" + safeGet(function () { return d.androidVersionSdkInt; }, ""));

  ctx.log("---- FAIL CONTEXT END ----");
}

/**
 * 失败截图:
 * - 使用 image 模块注入的 device.screenshot(filePath, imageType)
 * - 文件名包含:设备名 + step + 时间戳
 */
function takeFailScreenshot(ctx, d, stepName) {
  var file = LOG_DIR + "/" + safeFilePart(ctx.deviceName) + "__" + safeFilePart(stepName) + "__" + ts() + ".jpg";
  var ret = d.screenshot(file, IMG_TYPE);
  if (ret === 0) ctx.log("!! Screenshot saved: " + file);
  else ctx.log("!! Screenshot failed: " + lastError());
}

// ===================== 设备级上下文(日志对象) =====================

function makeCtx(d) {
  var name = devName(d);
  var logFile = LOG_DIR + "/" + safeFilePart(name) + "__" + ts() + ".log";
  var canWrite = ensureDir(LOG_DIR);

  return {
    deviceName: name,
    logFile: logFile,
    canWrite: canWrite && !!fs,
    log: function (line) {
      var msg = "[" + name + "] " + line;
      print(msg);
      if (this.canWrite) appendFile(this.logFile, msg + "\n");
    }
  };
}

// ===================== 单设备流程(核心业务) =====================

function runOnDevice(d) {
  var ctx = makeCtx(d);

  ctx.log("=== DEVICE BEGIN ===");
  ctx.log("info: " +
    safeGet(function(){return d.manufacturer;}, "") + " " +
    safeGet(function(){return d.model;}, "") + " " +
    safeGet(function(){return d.width;}, "") + "x" +
    safeGet(function(){return d.height;}, "")
  );

  var result = { ok: false, step: "", err: "", fg: "", act: "" };

  function failAt(stepName, errText) {
    result.ok = false;
    result.step = stepName;
    result.err = errText || "";

    // 失败上下文 + 截图
    writeFailContext(ctx, d, stepName, errText);
    takeFailScreenshot(ctx, d, stepName);

    ctx.log("=== DEVICE END (FAIL) ===");
    return result;
  }

  // ---------------- Step 1:启动 App ----------------
  var s1 = stepRun(ctx, "Step1 app.runApp(" + TARGET_PKG + ")", function () {
    var r = d.runApp(TARGET_PKG);
    if (r !== 0) throw new Error(lastError());
    // 启动后等待稳定
    sleep(WAIT_LAUNCH_MS);
    return r;
  });
  if (!s1.ok) return failAt("runApp", s1.err);

  // ---------------- Step 2:校验前台 + 读 Activity ----------------
  var s2 = stepRun(ctx, "Step2 app.isAppForeground + getForegroundApp/getActivity", function () {
    var r = d.isAppForeground(TARGET_PKG);
    if (r !== 0) throw new Error("not foreground: " + lastError());

    result.fg = d.getForegroundApp() || "";
    result.act = d.getActivity() || "";
    ctx.log("foregroundApp=" + result.fg);
    ctx.log("activity=" + result.act);
    return 0;
  });
  if (!s2.ok) return failAt("foregroundCheck", s2.err);

  // ---------------- Step 3:点击搜索框(input.click2) ----------------
  var s3 = stepRun(ctx, "Step3 input.click2(searchBox)", function () {
    var r = d.click2(SEARCH_X, SEARCH_Y, { dx: 0.08, dy: 0.04 });
    if (r !== 0) throw new Error(lastError());
    sleep(300);
    return r;
  });
  if (!s3.ok) return failAt("clickSearch", s3.err);

  // ---------------- Step 4:输入(tests.inputText)+ 回车 ----------------
  // 修改点:用 tests 模块输入,不再用逐字符 KEYCODE
  // 说明:
  // - d.inputText(content) 往“当前焦点输入框”输入
  // - 末尾加 '\n' 等价“回车/提交”
  var s4 = stepRun(ctx, "Step4 tests.inputText(SEARCH_TEXT + \\n)", function () {
    var r = d.inputText(SEARCH_TEXT + "\n");
    if (r !== 0) throw new Error("inputText failed: " + lastError());
    sleep(1000);
    return 0;
  });
  if (!s4.ok) return failAt("typeEnter", s4.err);

  // ---------------- Step 5:翻页(navKeys) ----------------
  var s5 = stepRun(ctx, "Step5 navKeys.pgDn + navKeys.shiftPgDn", function () {
    var r1 = d.pgDn();
    if (r1 !== 0) ctx.log("WARN pgDn ret=" + r1);
    sleep(250);

    var r2 = d.shiftPgDn();
    if (r2 !== 0) ctx.log("WARN shiftPgDn ret=" + r2);
    sleep(400);
    return 0;
  });
  if (!s5.ok) return failAt("navKeysPaging", s5.err);

  // ---------------- Step 6:返回 + 主页(系统键 -> input.send) ----------------
  var s6 = stepRun(ctx, "Step6 input.send(KEY_BACK) + input.send(KEY_HOME)", function () {
    press(d, tcConst.KEY_BACK);
    sleep(200);
    press(d, tcConst.KEY_HOME);
    sleep(400);
    return 0;
  });
  if (!s6.ok) return failAt("backHome", s6.err);

  // ---------------- Step 7:关闭 App ----------------
  var s7 = stepRun(ctx, "Step7 app.closeApp(" + TARGET_PKG + ")", function () {
    var r = d.closeApp(TARGET_PKG);
    if (r !== 0) ctx.log("WARN closeApp ret=" + r + " err=" + lastError());
    return r;
  });
  if (!s7.ok) return failAt("closeApp", s7.err);

  // 全流程成功
  result.ok = true;
  result.step = "done";
  ctx.log("=== DEVICE END (OK) ===");
  return result;
}

// ===================== main:只用 getDevices() 获取设备 =====================

function main() {
  if (Device.connectAll) Device.connectAll();

  // 用 getDevices()
  var devices = getDevices();
  if (!devices || devices.length === 0) {
    print("No selected devices. Please select devices first.");
    return;
  }

  // 尝试创建日志目录
  ensureDir(LOG_DIR);

  print("Selected devices: " + devices.length);

  var ok = 0;
  for (var i = 0; i < devices.length; i++) {
    var d = devices[i];
    var name = devName(d);

    print("\n==============================");
    print("RUN " + (i + 1) + "/" + devices.length + " : " + name);
    print("==============================");

    var r = runOnDevice(d);
    if (r.ok) {
      ok++;
      print("[" + name + "] OK");
    } else {
      print("[" + name + "] FAIL @" + r.step + " | " + r.err);
    }
  }

  print("\n===== SUMMARY =====");
  print("Success: " + ok + " / " + devices.length);
}

main();
TCHelp