在云手机使用场景中,用户操作的是运行于云端的虚拟设备,而本地终端仅作为显示与交互的载体。当云手机发生屏幕方向变化(如从竖屏切换至横屏)时,若本地客户端无法同步响应,将导致画面显示异常(如倒置、拉伸、黑边)或交互错位,严重影响用户体验。本文介绍如何在Android与iOS客户端SDK中实现本地屏幕方向与云端旋转指令的精准同步机制,确保本地界面与视频流始终与云端设备保持一致的屏幕朝向,从而提供沉浸式、无感知的远程操作体验。
背景
传统移动端应用通常依赖设备传感器或系统配置自动响应屏幕旋转。然而在云手机架构下,屏幕方向应由云端虚拟设备的状态驱动,而非本地物理设备的姿态。若直接启用系统自动旋转,会导致以下问题:
本地旋转与云端状态不同步,画面显示错误。
用户无意中转动手机即触发旋转,干扰正常操作。
视频渲染层(如 SurfaceView/TextureView 或 Metal/OpenGL View)未适配新方向,出现裁剪或形变。
因此必须主动接管旋转控制权,由云端下发明确的方向指令,本地客户端据此强制切换界面方向并同步调整视频渲染逻辑。
方案概述
本方案在Android与iOS平台上分别实现了以下核心能力:
统一的旋转指令接收机制
通过自定义DataChannel(如
wy_vdagent_default_dc)接收来自云端的旋转命令。使用标准化协议解析旋转参数(如
rotation = 0/1/3分别对应竖屏、左横屏、右横屏)。
本地界面方向强制同步
Android:调用
setRequestedOrientation()动态锁定Activity方向。iOS:重写
supportedInterfaceOrientations并结合setNeedsUpdateOfSupportedInterfaceOrientations(iOS 16+)或私有API实现方向切换。两者均禁用设备自动旋转(
shouldAutorotate = false/ 禁用传感器响应),确保仅响应云端指令。
视频渲染层精准适配
Android:启用
TextureView并调用setSurfaceRotation()直接旋转底层纹理,避免画面失真。iOS:通过
CGAffineTransform对StreamView进行旋转,并动态调整其中心点,确保画面居中且比例正确。
多方向支持
同时支持Portrait(竖屏)、Landscape Left(左横屏)和Landscape Right(右横屏)三种方向。
正确处理Home键位置差异,提升iOS设备在横屏下的交互一致性。
实现步骤
Android SDK本地旋转处理
// 禁止云手机横竖屏自适应
bundle.putBoolean(StreamView.CONFIG_DISABLE_ORIENTATION_CLOUD_CONTROL, true);
// 使用textureView
mStreamView = findViewById(R.id.stream_view);
mStreamView.enableTextureView(true);
//旋转处理
mStreamView.getASPEngineDelegate().addDataChannel(new DataChannel("wy_vdagent_default_dc") {
@Override
protected void onReceiveData(byte[] buf) {
String str = "";
try {
str = new String(buf, "UTF-8");
} catch (UnsupportedEncodingException e) {
str = new String(buf);
}
Log.i(TAG, "wy_vdagent_default_dc dc received " + buf.length + " bytes data:" + str);
CommandUtils.parseCommand(str, new CommandUtils.CommandListener() {
@Override
public void onCameraAuthorize() {
checkStartCpd();
}
@Override
public void onRotation(int rotation) {
runOnUiThread(() -> {
mStreamView.setSurfaceRotation(rotation);
if (rotation == 1) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
} else if (rotation == 3) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE);
} else {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
});
}
@Override
public void onUnknownCommand(String cmd) {
showError("未知命令:" + cmd);
}
});
}
@Override
protected void onConnectStateChanged(DataChannelConnectState state) {
Log.i(TAG, "wy_vdagent_default_dc dc connection state changed to " + state);
}
});iOS SDK本地旋转处理
list.info配置Supported interface orientations,启用应用对竖屏、左横屏、右横屏的支持。<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <array> <string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeRight</string> </array> </plist>创建
BaseViewController实现旋转相关逻辑。@implementation BaseViewController { NSInteger mRoration; } - (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor whiteColor]; } - (void)switchRoration:(NSInteger)roration { mRoration = roration; // 强制旋转到横屏(iOS 16+ 推荐方式) if (@available(iOS 16.0, *)) { [self setNeedsUpdateOfSupportedInterfaceOrientations]; } else { // 旧方法(已废弃,但兼容) NSNumber *value = @([self supportedInterfaceOrientations]); [[UIDevice currentDevice] setValue:value forKey:@"orientation"]; } } - (UIInterfaceOrientationMask)supportedInterfaceOrientations { switch (mRoration) { case 1: return UIInterfaceOrientationMaskLandscapeLeft; case 3: return UIInterfaceOrientationMaskLandscapeRight; default: return UIInterfaceOrientationMaskPortrait; } } - (BOOL)shouldAutorotate { return false; // 允许自动旋转到 supported 的方向 } // 可选:指定首选方向(用于 presentation) - (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation { switch (mRoration) { case 1: return UIInterfaceOrientationLandscapeLeft; case 3: return UIInterfaceOrientationLandscapeRight; default: return UIInterfaceOrientationPortrait; } } @end建立从云端到本地UI与渲染层的指令响应通路。
self.esdAgent = [[DemoEDSAgentChannel alloc] initWithParameter:DATA_CHANNEL_NAME]; self.esdAgent.streamView = self.streamView; self.esdAgent.viewController = self; [self.streamView addDataChannel:self.esdAgent];监听并响应云手机旋转指令。
@interface DemoEDSAgentChannel() <CommandListener> @property (nonatomic, assign) CGRect rect; @end @implementation DemoEDSAgentChannel - (void)setViewController:(BaseViewController *)viewController { self.rect = viewController.view.bounds; _viewController = viewController; } - (void)onConnectStateChanged:(ASPDCConnectState)state { NSLog(@"[DemoEDSAgentChannel] onConnectStateChanged %ld", state); if (state == OPEN) { // to send data } } - (void)onReceiveData:(NSData * _Nonnull)buf { NSString *string = [[NSString alloc] initWithData:buf encoding:NSUTF8StringEncoding]; NSLog(@"[DemoEDSAgentChannel] onConnectStateChanged %@", string); [CommandUtils parseCommand:string listener:self]; } #pragma mark - CommandListener - (void)onRotation:(NSInteger)value { NSLog(@"[DemoEDSAgentChannel] onRotation %ld", value); dispatch_async(dispatch_get_main_queue(), ^{ [self.viewController switchRoration:value]; if (value == 1 || value == 3) { self.streamView.center = CGPointMake(CGRectGetMidY(self.viewController.view.bounds), CGRectGetMidX(self.viewController.view.bounds)); self.streamView.transform = CGAffineTransformMakeRotation(-M_PI_2*value) ; } else { self.streamView.center = CGPointMake(CGRectGetMidX(self.rect), CGRectGetMidY(self.rect)); self.streamView.transform = CGAffineTransformIdentity; } }); }