HarmonyOS 6.1 云应用客户端适配实战(三):触摸输入与坐标映射

前言

在前两篇文章中,我们完成了环境搭建和视频渲染的适配。本文将介绍另一个核心功能:触摸输入的适配。这是云应用客户端交互体验的关键,涉及复杂的坐标转换和协议适配。

本文涉及的关键技术:

  • ArkTS 触摸事件 API
  • N-API 数据传递
  • 三级坐标系转换
  • Windows SendInput 协议
  • WebSocket 消息序列化(Protobuf)

核心挑战:

  1. HarmonyOS 本地坐标 → 远程桌面像素坐标
  2. 像素坐标 → Windows 归一化坐标(0-65535)
  3. 触摸手势 → 鼠标事件映射
  4. 绝对坐标 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 屏幕分辨率获取

关键问题: mCompressedPacketVideoWidthmCompressedPacketVideoHeight 在哪里设置?

解决方案: 在视频解码循环中设置

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

为什么这里设置?

  1. 视频帧携带了远程桌面的实际分辨率
  2. 每次收到帧都会更新,支持动态分辨率变化
  3. 确保触摸输入使用最新的屏幕尺寸

五、协议层: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 触摸无响应

问题表现: 触摸屏幕没有反应,服务端没有收到事件

排查步骤:

  1. 检查 ArkTS 事件是否触发

    typescript 复制代码
    .onTouch((event: TouchEvent) => {
        console.log('[Touch] Event type:', event.type)  // 应该打印
    })
  2. 检查 N-API 调用是否成功

    cpp 复制代码
    LOGI("SendPointerEvent called: x=%d y=%d", x, y);
  3. 检查 WebSocket 连接状态

    cpp 复制代码
    if (!websocket_client_) {
        LOGE("WebSocket not connected!");
        return false;
    }
  4. 检查控制权限

    cpp 复制代码
    if (mCurrentControlPrivilege != cloudapp::kMain) {
        LOGE("Not main controller!");
        return false;
    }

6.2 坐标偏移问题

问题表现: 触摸位置与实际响应位置不一致

可能原因:

  1. 本地坐标转换错误

    typescript 复制代码
    // 错误:使用了错误的分辨率
    const x = localX * 1920 / 1080  // ❌ 宽高比错误
    
    // 正确
    const x = localX * remoteW / localW  // ✅
  2. 归一化坐标计算错误

    cpp 复制代码
    // 错误:整数除法截断
    finalX = x * 65535 / mCompressedPacketVideoWidth;  // ❌
    
    // 正确:先乘后除,避免精度损失
    finalX = (x * 65535) / mCompressedPacketVideoWidth;  // ✅
  3. 屏幕分辨率未正确获取

    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 触摸输入的完整实现:

  1. ArkTS 层:捕获触摸事件,完成本地坐标到远程像素坐标的转换
  2. N-API 层:封装接口,传递参数到 C++ 层
  3. C++ 层:归一化坐标(像素→0-65535),序列化 Protobuf 消息
  4. 协议层:通过 WebSocket 发送到服务端

关键技术点:

  • 三级坐标转换:本地→远程像素→归一化
  • MOUSEEVENTF 标志位的正确使用
  • DOWN/UP 事件必须包含 MOVE 标志
  • 屏幕分辨率的动态获取

常见问题:

  • 触摸无响应:检查权限和连接状态
  • 坐标偏移:检查转换公式和分辨率
  • 画线问题:DOWN/UP 加上 MOVE 标志
  • 日志过滤:使用 {public} 标记

下一篇预告:

下一篇将介绍内存管理与崩溃修复,包括 C++ 析构顺序问题、ASIO 异步回调的生命周期管理等内容。


作者: Frame Not Work

日期: 2026年6月

系列文章: HarmonyOS 6.1 云应用客户端适配实战

相关推荐
●VON1 小时前
鸿蒙Flutter实战:日期选择器与截止日期高亮提醒
android·flutter·华为·harmonyos·鸿蒙
●VON2 小时前
鸿蒙Flutter实战:Material 3种子色亮暗双主题系统
android·flutter·harmonyos
慧海灵舟2 小时前
鸿蒙南向开发教程 Day 3:OpenHarmony 线程管理
华为·harmonyos·写文章,赢小鸿ai
想你依然心痛2 小时前
HarmonyOS 6(API 23)实战:打造“光味智厨“——AI烹饪新体验
人工智能·华为·ar·harmonyos·智能体
颜淡慕潇2 小时前
低成本搭建鸿蒙PC运行环境:基于 Docker 的 x86_64 服务器
服务器·docker·harmonyos
慧海灵舟3 小时前
鸿蒙南向开发教程 Day 6:事件标志组(Event Flags)
华为·harmonyos
weixin_604236674 小时前
华为三层交换机 极简完整版配置
运维·服务器·华为·华为交换机·华为交换机命令
慧海灵舟4 小时前
鸿蒙南向开发教程 Day 5:延时与系统节拍
华为·harmonyos
2501_919749034 小时前
鸿蒙 Flutter 实战:saver_gallery 5.1.0 适配 3.27-ohos 全流程
flutter·华为·harmonyos