HarmonyOS 6.1 云应用客户端适配实战(三):触摸输入与坐标映射
前言在前两篇文章中我们完成了环境搭建和视频渲染的适配。本文将介绍另一个核心功能触摸输入的适配。这是云应用客户端交互体验的关键涉及复杂的坐标转换和协议适配。本文涉及的关键技术ArkTS 触摸事件 APIN-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 API1.2 坐标系统对比坐标系范围说明本地触摸坐标0 ~ 屏幕尺寸HarmonyOS 设备的物理像素远程像素坐标0 ~ 远程分辨率Windows 桌面的像素坐标归一化坐标0 ~ 65535Windows SendInput 要求的格式为什么需要归一化坐标Windows SendInput API 使用MOUSEEVENTF_ABSOLUTE标志时要求坐标为 0-65535 的归一化值与实际屏幕分辨率无关。这样可以支持多显示器和不同分辨率。二、ArkTS 层触摸事件捕获2.1 基础触摸事件处理// ControlPage.etsEntryComponentstruct ControlPage{privateremoteWidth:number0// 远程桌面分辨率privateremoteHeight:number0privatelocalWidth:number0// 本地屏幕尺寸privatelocalHeight:number0build(){XComponent({...}).onAreaChange((oldArea,newArea){// 监听本地尺寸变化this.localWidthNumber(newArea.width)this.localHeightNumber(newArea.height)console.log([ControlPage] Local size:,this.localWidth,x,this.localHeight)}).onTouch((event:TouchEvent){this.handleTouch(event)})}handleTouch(event:TouchEvent){// 忽略无效事件if(this.dlcaPlayerId0||!event.touches||event.touches.length0){return}// 获取第一个触摸点单点触摸consttouchevent.touches[0]constlocalXMath.floor(touch.x)constlocalYMath.floor(touch.y)// 坐标转换本地 → 远程constremoteCoordsthis.localToRemote(localX,localY)// 发送到 Native 层this.sendTouchEvent(event.type,remoteCoords.x,remoteCoords.y)}}2.2 坐标转换实现// 本地坐标 → 远程像素坐标localToRemote(localX:number,localY:number):{x:number,y:number}{// 使用远程分辨率从视频流中获取constremoteWthis.remoteWidth0?this.remoteWidth:1920constremoteHthis.remoteHeight0?this.remoteHeight:1080// 使用本地尺寸constlocalWthis.localWidth0?this.localWidth:1080constlocalHthis.localHeight0?this.localHeight:720// 等比缩放constxMath.floor(localX*remoteW/localW)constyMath.floor(localY*remoteH/localH)return{x,y}}关键点使用Math.floor确保坐标为整数处理除零情况使用默认值保持宽高比例一致2.3 触摸事件类型映射sendTouchEvent(type:TouchType,x:number,y:number){letbuttonMask:numberswitch(type){caseTouchType.Down:// 按下LEFTDOWN | MOVE | ABSOLUTE 0x0002 | 0x0001 | 0x8000 0x8003buttonMask0x8003console.log([Touch] DOWN at,x,y)breakcaseTouchType.Move:// 移动MOVE | ABSOLUTE 0x0001 | 0x8000 0x8001buttonMask0x8001// 移动事件过多不打印日志breakcaseTouchType.Up:// 抬起LEFTUP | MOVE | ABSOLUTE 0x0004 | 0x0001 | 0x8000 0x8005buttonMask0x8005console.log([Touch] UP at,x,y)breakdefault:return}// 调用 N-APIconstretdlcaPlayer.sendPointerEvent(this.dlcaPlayerId,x,y,// 远程像素坐标buttonMask,// 鼠标事件标志0,// data (鼠标滚轮用)0,// modifiers (Ctrl/Shift等)0,0// xRel, yRel (相对移动))}MOUSEEVENTF 标志位详解标志值说明MOUSEEVENTF_MOVE0x0001移动鼠标MOUSEEVENTF_LEFTDOWN0x0002按下左键MOUSEEVENTF_LEFTUP0x0004抬起左键MOUSEEVENTF_ABSOLUTE0x8000使用绝对坐标为什么 DOWN/UP 也要包含 MOVEWindows SendInput 要求使用 ABSOLUTE 时必须同时设置 MOVE 标志这样可以确保鼠标先移动到目标位置再执行按下/抬起动作否则会出现从上次位置到当前位置画线的问题三、N-API 层接口封装3.1 N-API 函数定义// dlca_player_napi.ccstaticnapi_valueSendPointerEvent(napi_env env,napi_callback_info info){size_t argc8;napi_value args[8];napi_get_cb_info(env,info,argc,args,nullptr,nullptr);// 解析参数int32_tplayerId,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 实例autoclientGetClientById(playerId);if(!client){LOGE(Client not found: %d,playerId);napi_value result;napi_create_int32(env,-1,result);returnresult;}// 调用 C 方法boolsuccessclient-SendPointerEvent(x,y,buttonMask,data,modifiers,xRel,yRel);// 返回结果napi_value result;napi_create_int32(env,success?0:-1,result);returnresult;}注意事项参数数量和类型必须与 ArkTS 调用一致错误处理返回 -1 表示失败线程安全N-API 回调在 ArkTS 线程需要考虑跨线程访问3.2 参数验证boolCloudClient::SendPointerEvent(intx,inty,intbuttonMask,intdata,intmodifiers,intxRel,intyRel){// 检查是否允许发送鼠标事件if(!enable_mouse_event_send_){LOGW(Mouse event sending is disabled);returnfalse;}// 检查控制权限if(!IsRtcRemoteMode()){if(mCurrentControlPrivilege!cloudapp::kMain){LOGW(Not main controller, cannot send mouse event);returntrue;// 返回 true 避免 ArkTS 层报错}if(GetStatus()DLCA_STATUS_PAUSE){LOGW(Client is paused);returntrue;}}// 检查连接状态if(!mControl){LOGW(Control client is not initialized);returnfalse;}// 继续处理...}四、C 层坐标归一化4.1 问题分析从服务端日志发现直接发送像素坐标会导致鼠标只能在左上角很小的区域移动// 错误的坐标像素 x:857, y:433 → 鼠标在左上角 // 正确的坐标归一化 x:29269, y:26214 → 鼠标覆盖整个屏幕原因Windows SendInput 使用 ABSOLUTE 标志时要求坐标为 0-65535 范围。4.2 归一化实现boolCloudClient::SendPointerEvent(intx,inty,intbuttonMask,intdata,intmodifiers,intxRel,intyRel){// ... 前置检查 ...intfinalXx;intfinalYy;// 调试日志转换前LOGI(Before normalize: x%{public}d y%{public}d mask0x%{public}x screen%{public}dx%{public}d,x,y,buttonMask,mCompressedPacketVideoWidth,mCompressedPacketVideoHeight);// 如果使用绝对坐标进行归一化if((buttonMask0x8000)mCompressedPacketVideoWidth0mCompressedPacketVideoHeight0){// 归一化公式normalized (pixel * 65535) / screenSizefinalX(x*65535)/mCompressedPacketVideoWidth;finalY(y*65535)/mCompressedPacketVideoHeight;LOGI(After normalize: (%{public}d,%{public}d) - (%{public}d,%{public}d),x,y,finalX,finalY);}// 创建 Protobuf 消息automessagestd::make_sharedcloudapp::Message();message-set_type(cloudapp::kMouseEvent2);// 使用新协议cloudapp::MouseEvent*mouseEventnewcloudapp::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 发送returnmControl-AsyncPostMessage(message);}4.3 屏幕分辨率获取关键问题mCompressedPacketVideoWidth和mCompressedPacketVideoHeight在哪里设置解决方案在视频解码循环中设置// cloud_client.cc - VideoDecodeThread#ifdefined(OHOS)||defined(OHOS_PLATFORM)voidCloudClient::VideoDecodeThread(){while(!mStop){std::shared_ptrcloudapp::MessagemsgmVideoPacketQueue.Pop();if(!msg)continue;autoframemsg-frame();// 关键从视频帧中获取分辨率mCompressedPacketVideoWidthframe-width();mCompressedPacketVideoHeightframe-height();// 继续解码流程...}}#endif为什么这里设置视频帧携带了远程桌面的实际分辨率每次收到帧都会更新支持动态分辨率变化确保触摸输入使用最新的屏幕尺寸五、协议层Protobuf 消息5.1 消息定义// 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 协议版本选择// 根据服务端版本选择协议if(dl::CompareVersion(mServerVersion,2.22.0)0){message-set_type(cloudapp::kMouseEvent);// 旧版本}else{message-set_type(cloudapp::kMouseEvent2);// 新版本}5.3 序列化与发送// control_client.ccboolControlClient::AsyncPostMessage(constMessagePtrmessage){if(websocket_client_){// 序列化为二进制std::string binmessage-SerializeAsString();// 通过 WebSocket 发送websocket_client_-AsyncSendBin(bin);// 统计流量mSendBytesbin.size();LOGI(WS sent: type%{public}d size%{public}d,(int)message-type(),(int)bin.size());returntrue;}else{LOGE(WebSocket client is null);returnfalse;}}六、常见问题与解决方案6.1 触摸无响应问题表现触摸屏幕没有反应服务端没有收到事件排查步骤检查 ArkTS 事件是否触发.onTouch((event:TouchEvent){console.log([Touch] Event type:,event.type)// 应该打印})检查 N-API 调用是否成功LOGI(SendPointerEvent called: x%d y%d,x,y);检查 WebSocket 连接状态if(!websocket_client_){LOGE(WebSocket not connected!);returnfalse;}检查控制权限if(mCurrentControlPrivilege!cloudapp::kMain){LOGE(Not main controller!);returnfalse;}6.2 坐标偏移问题问题表现触摸位置与实际响应位置不一致可能原因本地坐标转换错误// 错误使用了错误的分辨率constxlocalX*1920/1080// ❌ 宽高比错误// 正确constxlocalX*remoteW/localW// ✅归一化坐标计算错误// 错误整数除法截断finalXx*65535/mCompressedPacketVideoWidth;// ❌// 正确先乘后除避免精度损失finalX(x*65535)/mCompressedPacketVideoWidth;// ✅屏幕分辨率未正确获取// 检查日志LOGI(Screen size: %dx%d,mCompressedPacketVideoWidth,mCompressedPacketVideoHeight);// 应该是实际分辨率不应该是 0x06.3 画线问题问题表现抬手后再按下会从上次位置连一条线到新位置原因DOWN 和 UP 事件没有包含 MOVE 标志解决方案// 错误caseTouchType.Down:buttonMask0x8002// ❌ 只有 LEFTDOWN | ABSOLUTE// 正确caseTouchType.Down:buttonMask0x8003// ✅ LEFTDOWN | MOVE | ABSOLUTE原理Windows SendInput 使用 ABSOLUTE 标志时必须同时指定 MOVE这样系统会先将鼠标移动到目标位置再执行按下/抬起否则鼠标会在当前位置→目标位置之间画线6.4 HarmonyOS 日志过滤问题问题表现日志中的参数值显示为private原因HarmonyOS hilog 默认过滤隐私数据解决方案// 错误LOGI(x%d y%d,x,y);// 显示为 xprivate yprivate// 正确使用 {public} 标记LOGI(x%{public}d y%{public}d,x,y);// 显示实际值适用场景调试坐标、尺寸等非敏感数据时使用不要在生产环境打印敏感信息即使使用 {public}七、性能优化7.1 事件节流触摸移动事件频率很高60 FPS需要适当节流privatelastMoveTime:number0privatemoveThrottleMs:number16// 约 60 FPShandleTouch(event:TouchEvent){if(event.typeTouchType.Move){constnowDate.now()if(now-this.lastMoveTimethis.moveThrottleMs){return// 跳过}this.lastMoveTimenow}// 处理事件...}7.2 批量发送对于高频事件可以批量打包发送// 暂不实现保留扩展性std::vectorcloudapp::MouseEventmEventBatch;if(mEventBatch.size()10){// 批量发送}7.3 异步发送// AsyncPostMessage 已经是异步的websocket_client_-AsyncSendBin(bin);// 不阻塞// 避免使用同步发送会阻塞渲染线程// SyncSendMessage(message); // ❌八、Android 对比Android 平台的触摸输入实现与 HarmonyOS 类似但有一些差异特性AndroidHarmonyOS触摸事件 APIView.onTouchEventXComponent.onTouchNative 接口JNIN-API坐标转换Java 层ArkTS 层日志 API__android_log_printOH_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 云应用客户端适配实战