前言
在前两篇文章中,我们完成了环境搭建和视频渲染的适配。本文将介绍另一个核心功能:触摸输入的适配。这是云应用客户端交互体验的关键,涉及复杂的坐标转换和协议适配。
本文涉及的关键技术:
- ArkTS 触摸事件 API
- N-API 数据传递
- 三级坐标系转换
- Windows SendInput 协议
- WebSocket 消息序列化(Protobuf)
核心挑战:
- HarmonyOS 本地坐标 → 远程桌面像素坐标
- 像素坐标 → Windows 归一化坐标(0-65535)
- 触摸手势 → 鼠标事件映射
- 绝对坐标 vs 相对坐标
一、触摸输入架构总览
1.1 数据流向
用户触摸屏幕
↓
ArkTS TouchEvent (本地坐标)
↓
坐标转换1: 本地坐标 → 远程像素坐标
↓
N-API 传递
↓
C++ SendPointerEvent (远程像素坐标)
↓
坐标转换2: 像素坐标 → 归一化坐标 (0-65535)
↓
Protobuf 序列化
↓
WebSocket 发送到服务端
↓
服务端 Windows SendInput API
1.2 坐标系统对比
| 坐标系 | 范围 | 说明 |
|---|---|---|
| 本地触摸坐标 | 0 ~ 屏幕尺寸 | HarmonyOS 设备的物理像素 |
| 远程像素坐标 | 0 ~ 远程分辨率 | Windows 桌面的像素坐标 |
| 归一化坐标 | 0 ~ 65535 | Windows SendInput 要求的格式 |
为什么需要归一化坐标?
Windows SendInput API 使用 MOUSEEVENTF_ABSOLUTE 标志时,要求坐标为 0-65535 的归一化值,与实际屏幕分辨率无关。这样可以支持多显示器和不同分辨率。
二、ArkTS 层:触摸事件捕获
2.1 基础触摸事件处理
typescript
// ControlPage.ets
@Entry
@Component
struct ControlPage {
private remoteWidth: number = 0 // 远程桌面分辨率
private remoteHeight: number = 0
private localWidth: number = 0 // 本地屏幕尺寸
private localHeight: number = 0
build() {
XComponent({...})
.onAreaChange((oldArea, newArea) => {
// 监听本地尺寸变化
this.localWidth = Number(newArea.width)
this.localHeight = Number(newArea.height)
console.log('[ControlPage] Local size:', this.localWidth, 'x', this.localHeight)
})
.onTouch((event: TouchEvent) => {
this.handleTouch(event)
})
}
handleTouch(event: TouchEvent) {
// 忽略无效事件
if (this.dlcaPlayerId < 0 || !event.touches || event.touches.length === 0) {
return
}
// 获取第一个触摸点(单点触摸)
const touch = event.touches[0]
const localX = Math.floor(touch.x)
const localY = Math.floor(touch.y)
// 坐标转换:本地 → 远程
const remoteCoords = this.localToRemote(localX, localY)
// 发送到 Native 层
this.sendTouchEvent(event.type, remoteCoords.x, remoteCoords.y)
}
}
2.2 坐标转换实现
typescript
// 本地坐标 → 远程像素坐标
localToRemote(localX: number, localY: number): {x: number, y: number} {
// 使用远程分辨率(从视频流中获取)
const remoteW = this.remoteWidth > 0 ? this.remoteWidth : 1920
const remoteH = this.remoteHeight > 0 ? this.remoteHeight : 1080
// 使用本地尺寸
const localW = this.localWidth > 0 ? this.localWidth : 1080
const localH = this.localHeight > 0 ? this.localHeight : 720
// 等比缩放
const x = Math.floor(localX * remoteW / localW)
const y = Math.floor(localY * remoteH / localH)
return { x, y }
}
关键点:
- 使用
Math.floor确保坐标为整数 - 处理除零情况(使用默认值)
- 保持宽高比例一致
2.3 触摸事件类型映射
typescript
sendTouchEvent(type: TouchType, x: number, y: number) {
let buttonMask: number
switch(type) {
case TouchType.Down:
// 按下:LEFTDOWN | MOVE | ABSOLUTE = 0x0002 | 0x0001 | 0x8000 = 0x8003
buttonMask = 0x8003
console.log('[Touch] DOWN at', x, y)
break
case TouchType.Move:
// 移动:MOVE | ABSOLUTE = 0x0001 | 0x8000 = 0x8001
buttonMask = 0x8001
// 移动事件过多,不打印日志
break
case TouchType.Up:
// 抬起:LEFTUP | MOVE | ABSOLUTE = 0x0004 | 0x0001 | 0x8000 = 0x8005
buttonMask = 0x8005
console.log('[Touch] UP at', x, y)
break
default:
return
}
// 调用 N-API
const ret = dlcaPlayer.sendPointerEvent(
this.dlcaPlayerId,
x, y, // 远程像素坐标
buttonMask, // 鼠标事件标志
0, // data (鼠标滚轮用)
0, // modifiers (Ctrl/Shift等)
0, 0 // xRel, yRel (相对移动)
)
}
MOUSEEVENTF 标志位详解:
| 标志 | 值 | 说明 |
|---|---|---|
| MOUSEEVENTF_MOVE | 0x0001 | 移动鼠标 |
| MOUSEEVENTF_LEFTDOWN | 0x0002 | 按下左键 |
| MOUSEEVENTF_LEFTUP | 0x0004 | 抬起左键 |
| MOUSEEVENTF_ABSOLUTE | 0x8000 | 使用绝对坐标 |
为什么 DOWN/UP 也要包含 MOVE?
- Windows SendInput 要求:使用 ABSOLUTE 时,必须同时设置 MOVE 标志
- 这样可以确保鼠标先移动到目标位置,再执行按下/抬起动作
- 否则会出现"从上次位置到当前位置画线"的问题
三、N-API 层:接口封装
3.1 N-API 函数定义
cpp
// dlca_player_napi.cc
static napi_value SendPointerEvent(napi_env env, napi_callback_info info) {
size_t argc = 8;
napi_value args[8];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
// 解析参数
int32_t playerId, x, y, buttonMask, data, modifiers, xRel, yRel;
napi_get_value_int32(env, args[0], &playerId);
napi_get_value_int32(env, args[1], &x);
napi_get_value_int32(env, args[2], &y);
napi_get_value_int32(env, args[3], &buttonMask);
napi_get_value_int32(env, args[4], &data);
napi_get_value_int32(env, args[5], &modifiers);
napi_get_value_int32(env, args[6], &xRel);
napi_get_value_int32(env, args[7], &yRel);
// 获取 CloudClient 实例
auto client = GetClientById(playerId);
if (!client) {
LOGE("Client not found: %d", playerId);
napi_value result;
napi_create_int32(env, -1, &result);
return result;
}
// 调用 C++ 方法
bool success = client->SendPointerEvent(x, y, buttonMask, data,
modifiers, xRel, yRel);
// 返回结果
napi_value result;
napi_create_int32(env, success ? 0 : -1, &result);
return result;
}
注意事项:
- 参数数量和类型必须与 ArkTS 调用一致
- 错误处理:返回 -1 表示失败
- 线程安全:N-API 回调在 ArkTS 线程,需要考虑跨线程访问
3.2 参数验证
cpp
bool CloudClient::SendPointerEvent(int x, int y, int buttonMask,
int data, int modifiers, int xRel, int yRel) {
// 检查是否允许发送鼠标事件
if (!enable_mouse_event_send_) {
LOGW("Mouse event sending is disabled");
return false;
}
// 检查控制权限
if (!IsRtcRemoteMode()) {
if (mCurrentControlPrivilege != cloudapp::kMain) {
LOGW("Not main controller, cannot send mouse event");
return true; // 返回 true 避免 ArkTS 层报错
}
if (GetStatus() == DLCA_STATUS_PAUSE) {
LOGW("Client is paused");
return true;
}
}
// 检查连接状态
if (!mControl) {
LOGW("Control client is not initialized");
return false;
}
// 继续处理...
}
四、C++ 层:坐标归一化
4.1 问题分析
从服务端日志发现,直接发送像素坐标会导致鼠标只能在左上角很小的区域移动:
// 错误的坐标(像素)
x:857, y:433 → 鼠标在左上角
// 正确的坐标(归一化)
x:29269, y:26214 → 鼠标覆盖整个屏幕
原因: Windows SendInput 使用 ABSOLUTE 标志时,要求坐标为 0-65535 范围。
4.2 归一化实现
cpp
bool CloudClient::SendPointerEvent(int x, int y, int buttonMask,
int data, int modifiers, int xRel, int yRel) {
// ... 前置检查 ...
int finalX = x;
int finalY = y;
// 调试日志:转换前
LOGI("Before normalize: x=%{public}d y=%{public}d mask=0x%{public}x screen=%{public}dx%{public}d",
x, y, buttonMask, mCompressedPacketVideoWidth, mCompressedPacketVideoHeight);
// 如果使用绝对坐标,进行归一化
if ((buttonMask & 0x8000) &&
mCompressedPacketVideoWidth > 0 &&
mCompressedPacketVideoHeight > 0) {
// 归一化公式:normalized = (pixel * 65535) / screenSize
finalX = (x * 65535) / mCompressedPacketVideoWidth;
finalY = (y * 65535) / mCompressedPacketVideoHeight;
LOGI("After normalize: (%{public}d,%{public}d) -> (%{public}d,%{public}d)",
x, y, finalX, finalY);
}
// 创建 Protobuf 消息
auto message = std::make_shared<cloudapp::Message>();
message->set_type(cloudapp::kMouseEvent2); // 使用新协议
cloudapp::MouseEvent* mouseEvent = new cloudapp::MouseEvent;
mouseEvent->set_x(finalX);
mouseEvent->set_y(finalY);
mouseEvent->set_button(buttonMask);
mouseEvent->set_data(data);
mouseEvent->set_modifiers(modifiers);
mouseEvent->set_rel_x(xRel);
mouseEvent->set_rel_y(yRel);
message->set_allocated_mouseevent(mouseEvent);
// 通过 WebSocket 发送
return mControl->AsyncPostMessage(message);
}
4.3 屏幕分辨率获取
关键问题: mCompressedPacketVideoWidth 和 mCompressedPacketVideoHeight 在哪里设置?
解决方案: 在视频解码循环中设置
cpp
// cloud_client.cc - VideoDecodeThread
#if defined(OHOS) || defined(OHOS_PLATFORM)
void CloudClient::VideoDecodeThread() {
while (!mStop) {
std::shared_ptr<cloudapp::Message> msg = mVideoPacketQueue.Pop();
if (!msg) continue;
auto frame = &msg->frame();
// 关键:从视频帧中获取分辨率
mCompressedPacketVideoWidth = frame->width();
mCompressedPacketVideoHeight = frame->height();
// 继续解码流程...
}
}
#endif
为什么这里设置?
- 视频帧携带了远程桌面的实际分辨率
- 每次收到帧都会更新,支持动态分辨率变化
- 确保触摸输入使用最新的屏幕尺寸
五、协议层:Protobuf 消息
5.1 消息定义
protobuf
// message.proto
message MouseEvent {
required int32 x = 1;
required int32 y = 2;
required int32 button = 3; // buttonMask
optional int32 data = 4; // 鼠标滚轮增量
optional int32 modifiers = 5; // Ctrl/Shift/Alt
optional int32 rel_x = 6; // 相对移动 X
optional int32 rel_y = 7; // 相对移动 Y
}
message Message {
required Type type = 1;
optional MouseEvent mouseevent = 10;
}
enum Type {
kMouseEvent = 5; // 旧协议
kMouseEvent2 = 80; // 新协议(支持更多字段)
}
5.2 协议版本选择
cpp
// 根据服务端版本选择协议
if (dl::CompareVersion(mServerVersion, "2.22.0") < 0) {
message->set_type(cloudapp::kMouseEvent); // 旧版本
} else {
message->set_type(cloudapp::kMouseEvent2); // 新版本
}
5.3 序列化与发送
cpp
// control_client.cc
bool ControlClient::AsyncPostMessage(const MessagePtr& message) {
if (websocket_client_) {
// 序列化为二进制
std::string bin = message->SerializeAsString();
// 通过 WebSocket 发送
websocket_client_->AsyncSendBin(bin);
// 统计流量
mSendBytes += bin.size();
LOGI("WS sent: type=%{public}d size=%{public}d",
(int)message->type(), (int)bin.size());
return true;
} else {
LOGE("WebSocket client is null");
return false;
}
}
六、常见问题与解决方案
6.1 触摸无响应
问题表现: 触摸屏幕没有反应,服务端没有收到事件
排查步骤:
-
检查 ArkTS 事件是否触发
typescript.onTouch((event: TouchEvent) => { console.log('[Touch] Event type:', event.type) // 应该打印 }) -
检查 N-API 调用是否成功
cppLOGI("SendPointerEvent called: x=%d y=%d", x, y); -
检查 WebSocket 连接状态
cppif (!websocket_client_) { LOGE("WebSocket not connected!"); return false; } -
检查控制权限
cppif (mCurrentControlPrivilege != cloudapp::kMain) { LOGE("Not main controller!"); return false; }
6.2 坐标偏移问题
问题表现: 触摸位置与实际响应位置不一致
可能原因:
-
本地坐标转换错误
typescript// 错误:使用了错误的分辨率 const x = localX * 1920 / 1080 // ❌ 宽高比错误 // 正确 const x = localX * remoteW / localW // ✅ -
归一化坐标计算错误
cpp// 错误:整数除法截断 finalX = x * 65535 / mCompressedPacketVideoWidth; // ❌ // 正确:先乘后除,避免精度损失 finalX = (x * 65535) / mCompressedPacketVideoWidth; // ✅ -
屏幕分辨率未正确获取
cpp// 检查日志 LOGI("Screen size: %dx%d", mCompressedPacketVideoWidth, mCompressedPacketVideoHeight); // 应该是实际分辨率,不应该是 0x0
6.3 画线问题
问题表现: 抬手后再按下,会从上次位置连一条线到新位置
原因: DOWN 和 UP 事件没有包含 MOVE 标志
解决方案:
typescript
// 错误
case TouchType.Down:
buttonMask = 0x8002 // ❌ 只有 LEFTDOWN | ABSOLUTE
// 正确
case TouchType.Down:
buttonMask = 0x8003 // ✅ LEFTDOWN | MOVE | ABSOLUTE
原理:
- Windows SendInput 使用 ABSOLUTE 标志时,必须同时指定 MOVE
- 这样系统会先将鼠标移动到目标位置,再执行按下/抬起
- 否则鼠标会在"当前位置→目标位置"之间画线
6.4 HarmonyOS 日志过滤问题
问题表现: 日志中的参数值显示为 <private>
原因: HarmonyOS hilog 默认过滤隐私数据
解决方案:
cpp
// 错误
LOGI("x=%d y=%d", x, y); // 显示为 x=<private> y=<private>
// 正确:使用 {public} 标记
LOGI("x=%{public}d y=%{public}d", x, y); // 显示实际值
适用场景:
- 调试坐标、尺寸等非敏感数据时使用
- 不要在生产环境打印敏感信息(即使使用 {public})
七、性能优化
7.1 事件节流
触摸移动事件频率很高(60+ FPS),需要适当节流:
typescript
private lastMoveTime: number = 0
private moveThrottleMs: number = 16 // 约 60 FPS
handleTouch(event: TouchEvent) {
if (event.type === TouchType.Move) {
const now = Date.now()
if (now - this.lastMoveTime < this.moveThrottleMs) {
return // 跳过
}
this.lastMoveTime = now
}
// 处理事件...
}
7.2 批量发送
对于高频事件,可以批量打包发送:
cpp
// 暂不实现,保留扩展性
std::vector<cloudapp::MouseEvent> mEventBatch;
if (mEventBatch.size() >= 10) {
// 批量发送
}
7.3 异步发送
cpp
// AsyncPostMessage 已经是异步的
websocket_client_->AsyncSendBin(bin); // 不阻塞
// 避免使用同步发送(会阻塞渲染线程)
// SyncSendMessage(message); // ❌
八、Android 对比
Android 平台的触摸输入实现与 HarmonyOS 类似,但有一些差异:
| 特性 | Android | HarmonyOS |
|---|---|---|
| 触摸事件 API | View.onTouchEvent | XComponent.onTouch |
| Native 接口 | JNI | N-API |
| 坐标转换 | Java 层 | ArkTS 层 |
| 日志 API | __android_log_print | OH_LOG_INFO |
共同点:
- 都需要三级坐标转换
- 都使用相同的 Windows SendInput 协议
- 都通过 WebSocket 发送 Protobuf 消息
九、总结
本文详细介绍了 HarmonyOS 触摸输入的完整实现:
- ArkTS 层:捕获触摸事件,完成本地坐标到远程像素坐标的转换
- N-API 层:封装接口,传递参数到 C++ 层
- C++ 层:归一化坐标(像素→0-65535),序列化 Protobuf 消息
- 协议层:通过 WebSocket 发送到服务端
关键技术点:
- 三级坐标转换:本地→远程像素→归一化
- MOUSEEVENTF 标志位的正确使用
- DOWN/UP 事件必须包含 MOVE 标志
- 屏幕分辨率的动态获取
常见问题:
- 触摸无响应:检查权限和连接状态
- 坐标偏移:检查转换公式和分辨率
- 画线问题:DOWN/UP 加上 MOVE 标志
- 日志过滤:使用 {public} 标记
下一篇预告:
下一篇将介绍内存管理与崩溃修复,包括 C++ 析构顺序问题、ASIO 异步回调的生命周期管理等内容。
作者: Frame Not Work
日期: 2026年6月
系列文章: HarmonyOS 6.1 云应用客户端适配实战