Android平台用户行为模拟脚本生成工具的设计与实现
一、项目概述
- 背景与意义:
- 项目目标:
- 系统整体架构:前后端分离架构图(前端 Vue3 + 后端 FastAPI + Android 设备)
二、技术选型
| 技术 | 用途 | 选型理由 |
|---|---|---|
| Vue 3 + TypeScript | 前端框架 | 响应式、类型安全 |
| GoJS | 节点画布 | 专业流程图引擎 |
| Element Plus | UI 组件库 | — |
| Scrcpy / WebSocket | 设备推流 | 低延迟投屏 |
| ADB / UIAutomator | 设备控制与UI dump | — |
| Python / FastAPI | 后端框架 | 原生支持 UIAutomator 方便书写 |
三、功能模块设计
3.1 脚本编辑器(ScriptCanvas)
3.11 节点画布的整体设计思路
1. 画布引擎选型
画布基于 GoJS 构建。GoJS 是一个专业的流程图/图形引擎,提供节点模板系统、数据绑定、连线路由、撤销历史等开箱即用的能力,使开发者可以专注于业务逻辑而非底层渲染。
GoJS 的核心模型是 数据驱动:nodeDataArray(节点数据数组)和 linkDataArray(连线数据数组)是唯一的数据源,UI 渲染完全由数据决定,修改数据即修改图形,符合 Vue 的响应式设计理念。
2. 节点模板体系
项目将节点按功能语义分为五类,每类对应一个独立的 GoJS 模板:
defaultTemplate → 普通动作节点(点击、输入、等待…)
jumpTemplate → 跳转节点
decisionTemplate → 分支判断节点
loopTemplate → 循环节点
appStateTemplate → APP 状态检测节点模板在画布初始化时一次性注册到 nodeTemplateMap,GoJS 根据节点数据中的 category 字段自动选用对应模板渲染。这样数据决定外观,新增节点类型时只需新增模板和 category 值,无需改动渲染逻辑。
3. 两套视觉风格的分层
项目中存在两套节点视觉语言,对应不同的语义复杂度:
1. 普通卡片风格(defaultTemplate / jumpTemplate)
适用于只有单一输入、单一输出的线性动作节点。采用白底圆角卡片,结构为「图标 + 标题 + 描述」三段式,简洁直观。端口用小圆点(makePort)表示。
2. UE Blueprint 风格(decisionTemplate / loopTemplate)
适用于有多路输出的控制流节点,视觉上借鉴虚幻引擎蓝图的设计:
- 深色背景卡片,彩色描边区分节点类型(橙色=分支,绿色=循环)
- 节点内部用行分隔,每行对应一个输出分支,行右侧紧贴边界放置三角形执行端口(
makeExecPort) - 三角形端口
angle: 90旋转为右向箭头(▷),视觉上直接传达"从此处流出"的语义
这种分层设计的好处是:视觉复杂度与功能复杂度匹配,用户看到卡片样式立刻能判断这是简单动作还是控制流节点。
4. 端口设计原则
端口是连线的锚点,设计上遵循以下原则:
1. 职责分离:输入端口只接入,输出端口只出发
fromLinkable: true → 可作为连线起点(输出)
toLinkable: true → 可作为连线终点(输入)两者互斥,防止连线方向混乱。
2. 位置即语义
| 位置 | 含义 |
|---|---|
| 左边界居中 | 统一的执行输入口 |
| 右边界(单个) | 唯一执行输出 |
| 右边界(多个,按行排列) | 多路分支输出,每路对应一种条件结果 |
3. 三角形端口的朝向约定
makeExecPort 的核心参数是 alignment(贴哪个位置)和 alignmentFocus(形状自身哪条边贴过去):
- 输入端口:
alignment=Left, focusSpot=Right→ 三角形右边贴节点左边界,三角形尖朝右指向节点内部,连线从背部(左侧)接入 - 输出端口:
alignment=Right, focusSpot=Left→ 三角形左边贴节点右边界,三角形尖朝右指向节点外部,连线从尖部(右侧)引出
这样无论缩放还是拖移,连线始终从正确的方向进出,与 UE 蓝图的视觉习惯一致。
5. 节点数据结构设计
每个节点在 nodeDataArray 中是一个扁平对象:
{
key: string, // 唯一ID,连线用 from/to 引用
category: string, // 决定渲染模板
type: string, // 决定属性面板表单
title: string, // 节点标题
desc: string, // 节点描述(由属性值自动拼合)
color: string, // 图标背景色
iconLabel: string, // 图标文字
loc: string, // 坐标 "x y",GoJS Point 序列化格式
...properties // 各类型的业务字段(condition/iterations/packageName…)
}category 和 type 刻意分开:category 是 GoJS 的模板选择键,type 是业务语义键,两者一一对应但职责不同,便于未来同一 type 切换不同外观模板。
6. 画布与属性面板的解耦
画布(ScriptCanvas)与属性面板(父组件)通过 emit 事件 解耦:
画布选中节点 → emit('node-selected', nodeData) → 父组件渲染属性表单
属性表单修改 → 父组件调用 canvas.updateNodeData() → 画布 commit 数据画布不感知表单结构,表单不感知 GoJS API,两者通过数据对象通信。这保证了画布模块的独立性,也让属性面板可以按需扩展字段而不影响画布逻辑。
3.12 各节点类型说明(普通、跳转、分支、循环、APP状态)及端口设计&连线标签
| 节点类型 | category | 端口 ID | 位置 | 方向 | 连线角色 | 连线标签 |
|---|---|---|---|---|---|---|
| 普通节点(默认) | '' | L | 左边界居中 | 输入(toLinkable) | 执行输入 | — |
R | 右边界居中 | 输出(fromLinkable) | 执行输出 | — | ||
| 跳转节点 | jump | L | 左边界居中 | 输入(toLinkable) | 执行输入 | — |
R | — | — | ||||
| 分支判断节点 | decision | T | 左边界居中 | 输入(toLinkable) | 执行输入 | — |
B | 右边界 行1中心 | 输出(fromLinkable) | True 分支 | True | ||
R | 右边界 行2中心 | 输出(fromLinkable) | False 分支 | False | ||
| 循环节点 | loop | L | 左边界居中 | 输入(toLinkable) | 执行输入 | — |
Body | 右边界 行1中心 | 输出(fromLinkable) | 循环体 | 循环体 | ||
R | 右边界 行2中心 | 输出(fromLinkable) | 完成后 | 完成后 | ||
| APP状态节点 | appState | L | 左边界居中 | 输入(toLinkable) | 执行输入 | — |
R1 | 右边界(前台运行行) | 输出(fromLinkable) | 前台运行 | 前台运行 | ||
R2 | 右边界(后台运行行) | 输出(fromLinkable) | 后台运行 | 后台运行 | ||
R3 | 右边界(未运行行) | 输出(fromLinkable) | 未运行 | 未运行 |
条件判断
元素存在性检测:判断某个元素是否出现在屏幕上:根据左侧元素树的
XPath定位。应用状态检测:结合 APP 状态节点(也相当于一个判断节点)来判断。
设备状态检测
网络类型(WiFi、飞行模式、4G、5G)
电池电量
屏幕是否开启
是否正在充电
前序节点状态
执行成功
执行失败
说明:
- 若条件判断节点两个输出端口其中有一个没有连线,且刚好执行到无连线的分支,则停止执行,除非其在循环体中
循环节点
该系统只实现了计数循环。
- 计数循环:和循环节点循环体连接的所有节点进行循环执行,执行结束后跳出循环。
- 条件循环:通过分支节点+计数循环实现,计数循环的循环体
Body最后接一个分支节点,如果满足则继续循环,否则走计数循环的完成后分支R
3.13 连线规则与标签
连线是脚本执行流程的有向边,由 GoJS 的 linkTemplate 统一定义样式与行为。
1. 连线样式
连线采用圆角折线路由(routing: AvoidsNodes, corner: 12),自动绕开节点避免遮挡,并在连线交叉处以跳弧方式(curve: JumpOver)区分,保持流程图整洁可读。线条默认显示为浅灰色(#cbd5e1),选中后高亮为蓝色(#3b82f6),末端带标准箭头,视觉上明确标示执行方向。连线支持拖拽重塑路径(reshapable)和两端重连(relinkableFrom/To),方便用户在编排过程中调整流程结构。
2. 端口约束
GoJS 模型中通过 linkFromPortIdProperty 和 linkToPortIdProperty 记录连线所绑定的具体端口。由于各端口已通过 fromLinkable/toLinkable 明确了方向,用户只能从输出端口拖出、连入输入端口,系统在交互层面天然防止了逆向连线。
3. 连线标签
对于具有多路输出的节点,连线上会根据其来源端口自动显示语义标签,帮助用户区分不同分支的含义。标签以白底圆角小标牌的形式悬浮在连线中段,具体映射规则如下:
| 来源节点 | 来源端口 | 标签文字 |
|---|---|---|
| 分支判断节点 | B | True |
| 分支判断节点 | R | False |
| 循环节点 | Body | 循环体 |
| 循环节点 | R | 完成后 |
| APP状态节点 | R1 | 前台运行 |
| APP状态节点 | R2 | 后台运行 |
| APP状态节点 | R3 | 未运行 |
标签通过 linkLabelConverter 函数在运行时动态计算,根据连线数据的 from 字段查找来源节点类型,再根据 fromPort 字段确定标签内容。对于普通单路输出节点,函数返回空字符串,标签面板自动隐藏,不干扰简单流程的视觉效果。
3.14 撤销/重做、缩放、全屏
1. 撤销与重做
画布直接复用 GoJS 内置的命令处理器(commandHandler),通过调用 undo() 和 redo() 实现操作历史管理。GoJS 的历史记录机制在每次 model.commit() 时自动记录一条操作快照,因此所有对节点和连线的增删改操作均可无缝撤销,无需额外维护操作栈。撤销/重做按钮悬浮于画布工具栏右侧,随时可用。
2. 缩放
缩放同样代理给 GoJS 命令处理器的 increaseZoom() 和 decreaseZoom() 方法,每次调用按固定比例放大或缩小画布视图。GoJS 的视图变换与节点坐标系相互独立,缩放操作不影响节点的实际位置数据,数据持久化时存储的始终是逻辑坐标。
3. 全屏
全屏功能通过浏览器原生 Fullscreen API 实现,以 GoJS 画布容器 div 作为全屏目标元素。点击全屏按钮时调用 requestFullscreen(),将画布区域扩展至整个屏幕,便于在节点数量较多时总览脚本全貌;再次点击则调用 document.exitFullscreen() 退出。若浏览器拒绝全屏请求(如权限限制),则通过 ElMessage 向用户提示错误原因。
3.15 JSON 预览
JSON 预览模式以只读方式展示当前画布的完整数据结构,是脚本数据序列化结果的直接呈现,同时也为开发调试提供透明的数据视图。
点击工具栏中的「JSON」按钮后,系统调用 GoJS 模型的 toJson() 方法将当前 nodeDataArray 和 linkDataArray 序列化为 JSON 字符串,再经 JSON.parse + JSON.stringify 以 2 格缩进重新格式化,最终显示在深色背景的代码面板中(bg-slate-900,绿色等宽字体),风格类似代码编辑器,便于阅读层级结构。
由于 JSON 预览与画布视图共用同一份模型数据,每次切换到 JSON 模式时均重新生成,确保内容始终与画布当前状态同步。切回「画布」模式时,GoJS 画布重新显示,JSON 面板隐藏,两者通过 v-show 控制可见性,不涉及组件的销毁与重建,画布状态(视图位置、选中节点等)得以完整保留。
3.16 脚本校验
| 校验项 | 说明 | 触发时机 |
|---|---|---|
| 孤岛节点 | 除了起始节点外,不允许有入度为 0 的节点 | 编辑时 |
| 节点入边和出边限制 | 普通节点最多一个入边和一条出边;循环节点和分支节点有两条出边;获取 APP 状态节点三条出边 | 编辑时 |
| 节点属性完整性 | 节点的所有属性被正确填写 | 编辑时 |
| 环路检测 | 不允许出现环路 | 保存提交时 |
| 唯一入口节点 | 入口节点有且唯一 | 保存提交时 |
说明:
- 入度的统计主要是为了确定起始节点和校验整个图表,因此跳转节点的虚边不算入度(如果算了对上述两个作用有影响)。
3.2 节点属性面板
3.21 右侧属性表单的动态渲染机制
右侧属性面板的核心设计思路是配置驱动渲染:表单的结构不是在模板中硬编码的,而是在运行时根据当前选中节点的类型,从 useActions.ts 中查找对应的字段描述数组(fields),由 Vue 模板动态遍历生成。
1. 字段描述结构
每个节点类型在 useActions.ts 中声明一个 ActionItem,其中 fields 数组描述该类型所有可配置项:
interface ActionField {
name: string; // 字段键名,对应节点数据中的属性
label: string; // 表单标签文字
type: 'input' | 'select' | 'number' | 'textarea' | 'radio' | 'pattern';
placeholder?: string;
options?: string[]; // select / radio 的选项列表
visibleOn?: { // 条件显隐:依赖另一字段的值
field: string;
value: string | number | boolean | (string | number | boolean)[];
};
defaultValue?: any;
}2. 动态表单渲染流程
- 用户在画布上点击节点,GoJS 触发
ChangedSelection事件,画布组件通过emit('node-selected', nodeData)将节点数据传给父组件,父组件将其赋值给响应式变量selectedNode。 - 父组件通过
currentActionDef计算属性,用selectedNode.type在allActions中查找对应的ActionItem。 - 模板中
v-for="field in currentActionDef.fields"遍历字段列表,根据field.type渲染不同的输入控件(数字输入框、下拉选择、单选组、文本域等)。
3. 条件显隐
部分字段的显示依赖于其他字段的当前值,例如「点击」节点中,选择"坐标"时显示 X/Y 坐标输入框,选择"元素 ID"时显示元素 ID 输入框。这通过 isFieldVisible(field) 函数实现:
function isFieldVisible(field: ActionField): boolean {
if (!field.visibleOn) return true;
const dependency = nodeFormData[field.visibleOn.field];
const target = field.visibleOn.value;
if (Array.isArray(target)) return target.includes(dependency);
return dependency === target;
}函数读取当前表单数据中被依赖字段的值,与 visibleOn.value 比较,支持单值和多值(includes)两种匹配方式。
4. 节点切换时的表单重置
当 selectedNode 发生变化时,watch(selectedNode, ...) 监听器被触发,执行以下步骤:清空 nodeFormData 的全部键值,从新节点的 ActionItem.fields 中读取字段,将节点数据中已存储的属性值回填到表单,对于 radio 类型字段若节点数据中无值则自动选中第一个选项,最后设置 isUpdatingForm 标志阻止此次填表过程触发自动保存。
3.22 跳转节点的目标选取交互
跳转节点的核心属性是目标节点(targetNodeId),其值是画布上另一个节点的 key。由于用户不便手动输入节点 ID,系统设计了一套可视化拾取模式,让用户直接点击画布上的节点来设置跳转目标。
1. 进入拾取模式
属性面板中的「选择目标节点」按钮点击后,父组件调用画布暴露的 startPickTarget() 方法。画布进入拾取模式后:
- 设置全局状态
isPickingTarget = true并 emitpick-mode-changed通知父组件,父组件将按钮文字改为"请点击画布上的目标节点..."并禁用按钮; - 画布鼠标指针变为准星(
cursor: crosshair); - 当前跳转节点以外的所有节点透明度降低至 0.3,视觉上突出可选目标;
- 注册 Escape 键监听器,随时可取消选取。
2. 拾取确认
画布监听 ObjectSingleClicked 事件,当用户点击某个节点时,调用 handleNodeClickForPicking() 退出拾取模式(恢复节点透明度、重置指针、解除键盘监听),然后 emit target-picked 事件,将被点击节点的 key 和 title 传给父组件。
父组件的 onTargetPicked() 接收到事件后,将 targetNodeId 和 targetNodeName 写入 nodeFormData,并立即调用 saveNodeData() 写回画布模型。
3. 属性面板状态展示
目标选取完成后,属性面板中 targetNodeId 字段区域切换为已选状态,显示目标节点名称,并提供"清除"按钮。targetNodeName 字段在 fields 定义中存在(用于内部传递),但模板中通过 field.name !== 'targetNodeName' 条件将其从表单渲染中排除,避免重复展示。
4. 跳转虚线指示
选中跳转节点时,画布还会额外绘制一条临时虚线连接到目标节点(updateJumpLinkAdornment),直观地展示当前跳转关系,该虚线不参与正式的连线数据,仅作为视觉辅助。
3.23 表单数据写回 GoJS 模型
属性面板的任何修改都需要同步回 GoJS 的 nodeDataArray,确保画布视图与数据层保持一致。
1. 自动保存机制
表单数据存储在响应式对象 nodeFormData 中,父组件通过 watch(nodeFormData, ..., { deep: true }) 深度监听其所有字段变化。一旦检测到变化(且不处于节点切换的填表过程中),立即调用 saveNodeData():
function saveNodeData() {
if (!selectedNode.value) return;
canvasRef.value?.patchNodeData(selectedNode.value.key, { ...toRaw(nodeFormData) });
}这里用 toRaw 去除 Vue 的响应式代理再传入,避免 GoJS 内部处理代理对象时产生异常。
2. 写入 GoJS 模型(patchNodeData)
画布组件中的 patchNodeData() 函数通过 GoJS 的 model.commit() 事务批量修改节点数据:
myDiagram.model.commit((m) => {
const nodeData = m.findNodeDataForKey(key);
Object.keys(formData).forEach(k => {
if (formData[k] !== undefined) m.set(nodeData, k, formData[k]);
});
// 同步更新节点卡片上的描述文字
m.set(nodeData, 'desc', ...);
}, null);所有修改在同一个 commit 事务中完成,GoJS 会将这次提交作为一条完整的操作记录加入撤销历史,保证撤销行为的原子性。
3. 描述文本的自动派生
写回时还同步更新节点卡片上显示的 desc 字段。desc 不是用户直接输入的,而是从各字段值中自动拼合:遍历当前 fields,将所有当前可见字段的标签和值拼接为多行字符串。跳转节点例外,其描述固定为"跳转至:目标节点名称"。这样节点卡片上始终展示配置摘要,用户无需打开属性面板就能一眼了解节点的关键参数。
4. 防重入标志
节点切换时,监听器需要将新节点的已有属性值填入 nodeFormData,这一过程会触发 nodeFormData 的深度 watch。为防止这次填表被误判为用户修改并触发保存,切换时先将 isUpdatingForm 置为 true 阻断 watch 回调,填表完成后在 nextTick 中重置为 false。
3.3 设备推流与交互(ScrcpyStream)
3.31 推流原理与接入方式
3.32 鼠标手势识别(点击 / 滑动 / 长按)
3.33 坐标映射算法(CSS 像素 → 设备物理像素)
3.34 操作日志面板
3.4 UI 元素检查器(UiInspector)
3.41 Dump UI 流程(ADB → XML → 解析)
3.42 元素树的层级渲染与缩进
3.43 鼠标悬停高亮与坐标对齐
3.44 检查模式与推流模式的切换
3.5 调试执行
3.51 调试模式的状态机(stopped / running / paused)
1. JSON 数据的预处理
将 JSON 数据转化为 Python 中的字典类型 instructions 供之后的状态机执行时使用。
instructions[node.id] = {
"type": node.type,
"properties": props,
"next": {}
}2. 状态机的执行
3.52 WebSocket 与后端通信协议
3.53 断点、日志、节点高亮
四、关键技术实现
- 坐标映射:canvas.getBoundingClientRect + canvas.width/height 缩放推导
- 手势分类算法:distance > 15px → 滑动;duration ≥ 600ms → 长按;否则 → 点击
- flex 布局保护:
flex: 1 1 0px防止内容撑破导航栏的方案
五、系统界面与使用说明
- 各主要页面截图(脚本列表、编辑器、调试控制台)
- 典型使用流程(新建脚本 → 拖拽编排 → 调试运行)
六、测试
- 单元测试 / 手动测试用例
- 主要功能验证结果
- 在测试机(小米11)上的兼容性验证
七、总结与展望
- 已实现的功能
- 不足与局限
- 后续可扩展的方向(如多设备支持、更多节点类型、AI 生成脚本等)
优先级建议:可以在坐标映射、GoJS 模板定制、手势识别这几块展开写。