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) | 未运行 | 未运行 |
| 节点类型 | 属性 1 | 属性 2 |
|---|---|---|
| 点击 | x 坐标 y 坐标 | 元素 XPath |
| 长按 | x 坐标 y 坐标 | 元素 XPath |
| 滑动 | 起始坐标: x 坐标 y 坐标 结束坐标: x 坐标 y 坐标 | 无 |
| 输入文本 | 输入框 XPath 内容 | 无 |
| 等待 | 等待时间(ms) | 无 |
| 跳转 | 指定节点 | 无 |
| 分支判断 | 见下 | 无 |
| 循环 | 循环次数 | 无 |
| 打开 APP | APP 包名 | 无 |
| 关闭 APP | APP 包名 | 无 |
| 切换到前台 | APP 包名 | 无 |
| 获取 APP 状态 | APP 包名 | 无 |
| 解锁屏幕 | 密码 | 无 |
| 切换网络 | WiFi/移动/飞行 | 无 |
| 调整亮度 | 亮度百分比 | 无 |
| 模拟通知 | 标题 内容 | 无 |
分支判断
元素存在性检测:判断某个元素是否出现在屏幕上:根据左侧元素树的
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 脚本校验
脚本抽象成一个有向带环图。
| 校验项 | 说明 | 触发时机 |
|---|---|---|
decision 节点,最好强制要求用户至少连一根线(True 或 False),否则给个警告:“判断节点没有连接任何后续动作,可能会导致流程过早结束。” | ||
| 节点属性完整性 | 节点的所有属性被正确填写,没有填写则在图上高亮提醒 | 编辑时 |
| 唯一出边 | 一个输出端口对应一条边,添加属性限制,无法连出第二条线 | 编辑时 |
| 起点校验 | 无法使用入度为 0 判断起点,专门设置唯一一个起点 | 保存提交时 |
| 孤岛节点 | 从起点开始,做一次深度优先搜索(DFS)或广度优先搜索(BFS),把遍历到的节点 ID 存入一个 visited 集合。最后对比一下:如果 len(visited) < len(all_nodes),说明存在孤岛。 | 保存提交时 |
| 死循环熔断校验 | 设置一个最大步数,如果超过该部署说明有死循环 | 运行时 |
说明:
- 编辑时说明是在前端做校验,能够实时显示错误。
- 提交保存时是按下保存按钮在后端进行逻辑校验。
- 运行时是在脚本运行后报错。
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)
设备推流模块负责将 Android 设备的实时屏幕画面呈现在前端界面中,并将用户的鼠标操作转换为设备触控指令下发。该模块封装为独立的 ScrcpyStream 组件,通过 WebSocket 与后端保持长连接,实现低延迟的双向通信。
3.31 推流原理与接入方式
1. 整体链路
推流分为两个独立通道:视频流(单向:设备→前端) 和 控制流(双向:前端↔设备),通过同一条 WebSocket 连接复用。
📝 【后端留白】
此处请补充:后端如何通过 ADB 启动 scrcpy-server(或直接控制 scrcpy 进程),以及如何将设备端的 H.264 视频流转发到 WebSocket 端点/api/ws/device/stream,包括帧元数据格式(send_frame_meta=true产生的 8字节PTS + 4字节帧长 + H.264裸流的封包结构),以及分辨率消息的推送时机与 JSON 格式。
建议写作角度:后端接收到连接请求后如何选取设备(?serial=参数)、启动推流进程、并行维护控制通道,帧数据如何以二进制消息发送给前端。
Android 设备
│ ADB Local Abstract Socket ("scrcpy")
▼
ScrcpyServer(后端)
│ WebSocket
▼
前端浏览器2. 前端接入流程
前端在用户点击「连接设备」后执行以下步骤:
初始化解码环境:创建
H264Decoder实例,在 DOM 中动态插入一个<canvas>元素作为渲染目标;同时创建FrameExtractor实例负责后续的帧边界解析。建立 WebSocket 连接:根据当前页面协议(http/https)自动选用 ws/wss,携带设备序列号参数建立连接:
ws(s)://host/api/ws/device/stream?serial=<设备序列号>消息分流处理:WebSocket 收到的消息分为两类——字符串类型的 JSON 消息(如分辨率通知
{type: "resolution", width, height})和二进制类型的 ArrayBuffer 视频帧数据。视频帧数据交由FrameExtractor处理。
3. FrameExtractor 帧边界解析
scrcpy 开启 send_frame_meta=true 后,每个视频帧以固定格式封包:前 8 字节为 PTS 时间戳,第 8–11 字节(大端序 uint32)为帧数据长度,其后跟随对应字节数的 H.264 裸流数据。
FrameExtractor 维护一个内部字节缓冲区,每次收到 WebSocket 二进制消息都追加入缓冲,随后循环尝试解析:当缓冲区字节数大于等于 12(头部大小)且剩余数据满足帧长时,切割出一帧完整数据并回调 onFrame,同时通过扫描 NAL 单元起始码和类型字节判断是否为关键帧(IDR,NAL 类型 5)。
4. H264Decoder WebCodecs 解码
H264Decoder 封装了浏览器原生的 VideoDecoder API(WebCodecs,Chrome 94+ 支持)。收到第一帧数据时,扫描其中的 SPS NAL 单元(类型 7),从中提取 profile、constraints、level 三段编码参数拼合成 AVC codec 字符串(如 avc1.640028),调用 VideoDecoder.configure() 完成初始化,并开启 optimizeForLatency: true 降低解码延迟。
后续每帧调用 VideoDecoder.decode() 提交 EncodedVideoChunk,解码完成后在 output 回调中通过 CanvasRenderingContext2D.drawImage(frame, 0, 0) 将 VideoFrame 直接绘制到 canvas。若检测到分辨率变化(如横竖屏切换),自动调整 canvas 的 width、height 和 aspectRatio 样式,并通知组件更新状态栏显示的分辨率信息。
用户在浏览器中看到的推流画面是经过缩放的 CSS 渲染结果,其尺寸与设备屏幕的物理像素分辨率(如 1080×2400)之间存在比例差异,需要精确换算才能得到有意义的设备坐标。
ScrcpyStream 内部——归一化坐标方案
ScrcpyStream 内部的 toNormalized() 函数将鼠标位置转换为 0.0~1.0 的归一化比例再发送给后端,由后端还原为设备坐标。转换步骤如下:
- 通过
canvas.getBoundingClientRect()获取 canvas 在页面中的 CSS 显示区域; - 计算
object-fit: contain模式下的实际缩放比S = min(cssW/videoW, cssH/videoH); - 计算图像在 canvas 内居中渲染时的黑边偏移量
offsetX = (cssW - videoW*S) / 2; - 用鼠标相对坐标减去黑边偏移,再除以实际渲染尺寸,得到归一化坐标;
- 使用
Math.max(0, Math.min(1, ...))钳制越界值,防止点击到黑边区域时坐标超出范围。
📝 【后端留白】
此处请补充:后端收到归一化坐标后,如何将其乘以设备实际分辨率还原为绝对坐标,并通过 ADB input tap / scrcpy 控制通道注入触控事件。
建议写作角度:控制消息的 JSON 格式定义、后端对 touchDown/touchMove/touchUp 三种消息的处理逻辑、以及如何保证指令实时性(是否使用独立的控制 WebSocket 通道)。
操作日志层——直接缩放至设备坐标
操作日志层的 getStreamPos() 函数目标是直接获得设备物理坐标(原点左上角,横向为 X 轴,纵向为 Y 轴),供日志显示使用:
// 1. 从 ScrcpyStream 组件 DOM 中查找内部 canvas 元素
const streamCanvas = scrcpyEl?.querySelector('canvas');
// 2. 计算鼠标相对 canvas 元素左上角的 CSS 偏移
const localX = e.clientX - rect.left;
const localY = e.clientY - rect.top;
// 3. 利用 canvas 的固有属性(canvas.width / canvas.height)获取设备分辨率
// 直接按比例换算为设备坐标
x = Math.round(localX * (devW / rect.width));
y = Math.round(localY * (devH / rect.height));该方案通过 scrcpyRef.value.$el.querySelector('canvas') 穿透组件边界,直接获取解码器动态创建的 canvas 元素。canvas 的 width/height 属性由 WebCodecs 解码器在首帧解码后自动同步为设备实际分辨率,因此该值始终准确。
函数设有三级降级兜底:① canvas 元素(首选)→ ② video 元素 → ③ wrapper 容器的 CSS 像素,确保在推流尚未就绪时也不会抛出异常。
3.34 操作日志面板
操作日志面板占据推流区域右侧的 XML 面板位置(当不处于 UI 检查模式时显示),实时记录用户在推流画面上执行的所有手势操作,辅助用户了解当前操作的坐标和行为类型。
日志数据结构
interface StreamOp {
type: 'click' | 'long-press' | 'swipe'
x1: number; y1: number // 起始点(点击/长按为操作点)
x2?: number; y2?: number // 终止点(仅滑动有)
direction?: string // 滑动方向文字(仅滑动有)
duration?: number // 持续时长 ms(仅长按有)
}日志管理策略
日志以逆序插入(unshift)的方式追加,最新操作始终显示在列表顶部,便于即时查看。列表上限为 10 条,超出时自动删除末尾最旧的一条,避免长时间操作后列表过长影响可读性。用户也可通过「清空」按钮一键重置。
面板与 UI 检查模式的互斥
操作日志面板和 UI 元素检查面板共用同一块区域,通过 v-if="uiXml && uiDumpMode" 控制检查器的显示,v-else 显示操作日志面板。当用户点击「Dump UI」进入检查模式时,操作日志面板自动隐藏;退出检查模式后,日志面板重新出现,且已记录的历史日志得以保留(ref 数据未被销毁)。
日志条目的视觉设计
每条日志由两部分组成:左侧为彩色标签(点击=蓝色、长按=橙色、滑动=紫色),右侧显示设备坐标和附加信息(滑动显示起止坐标及方向箭头,长按显示持续时长)。标签颜色与手势类型一一对应,用户无需阅读文字即可快速区分操作类型。
3.32 鼠标手势识别(点击 / 滑动 / 长按)
项目中存在两套独立的手势识别逻辑,分别服务于不同目的:
ScrcpyStream内部(onMouseDown/Move/Up/Leave):负责实时向后端发送触控控制消息(touchDown、touchMove、touchUp),驱动设备端的真实触摸事件,是操控设备的执行层。ScriptEditView的 stream-wrapper 层(onStreamMouseDown/Move/Up/Leave):在推流画面的外层div上独立监听鼠标事件,专门负责手势分类和操作日志记录,不与 ScrcpyStream 内部的控制逻辑耦合。
手势分类算法
手势分类在 onStreamMouseUp(或鼠标离开时的 onStreamMouseLeave)中统一执行,使用两个阈值区分三种操作类型:
按下时记录:起始坐标 (x1, y1)、时间戳 t0
抬起时计算:终止坐标 (x2, y2)、持续时长 duration = now - t0
位移距离 dist = √((x2-x1)² + (y2-y1)²)
判断逻辑:
if dist > 15(设备像素) → 滑动(swipe)
else if duration ≥ 600ms → 长按(long-press)
else → 点击(click)距离阈值 15 像素(设备坐标)用于过滤轻微的手抖,时长阈值 600ms 与 Android 系统的长按触发时机一致。
滑动方向判断
滑动手势额外计算方向,通过比较水平和垂直位移的绝对值大小区分横向/纵向,再由符号确定具体方向:
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? '→ 右' : '← 左';
else return dy > 0 ? '↓ 下' : '↑ 上';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 模板定制、手势识别这几块展开写。