HarmonyOS的分布式特性详解
1. 介绍
目前家庭电视机主要通过其自带的遥控器进行操控,实现的功能较为单一。例如,当我们要在TV端搜索节目时,电视机在遥控器的操控下往往只能完成一些字母或数字的输入,而无法输入其他复杂的内容。分布式遥控器将手机的输入能力和电视遥控器的遥控能力结合为一体,从而快速便捷操控电视。
分布式遥控器的实现基于HarmonyOS分布式数据服务和公共事件。当TV端在搜索框聚焦时,自动拉起手机端的输入应用,在手机端输入时会将输入的内容同步显示在TV端搜索框;点击确定按钮会根据输入的内容搜索相关节目,还可以通过点击方向键(上下左右)将焦点移动到我们想要的节目上,再点击确定按钮进行播放,效果图如下:
说明:
为便于演示,本篇codelab所指TV均使用支持HarmonyOS手机代替
图1 TV端主页默认页面
图2 设备选择弹框,点击设备图标自动拉起手机端输入应用图3 手机端输入应用页面
图4 手机端输入,TV同步显示输入内容,点击 ok键搜索内容,焦点聚焦搜索结果
图5 点击OK,播放选中的视频内容
2. 搭建HarmonyOS环境
- 安装DevEco Studio,详情请参考DevEco Studio下载。
- 设置DevEco Studio开发环境,DevEco Studio开发环境需要依赖于网络环境,需要连接上网络才能确保工具的正常使用,可以根据如下两种情况来配置开发环境:
- 如果可以直接访问Internet,只需进行下载HarmonyOS SDK操作。
- 如果网络不能直接访问Internet,需要通过代理服务器才可以访问,请参考配置开发环境。
- 如需要在手机中运行程序,则需要提前申请证书。
- 准备密钥和证书请求文件
- 申请调试证书
您可以通过如下设备完成codelab: - 开启了开发者模式的两部HarmonyOS真机
3. 代码结构解读
在本篇codelab中我们只对核心代码进行讲解,您可以在最后的参考中下载完整代码。首先来介绍下整个工程的代码结构:
- adapter:DevicesListAdapter,用于向ListContainer控件中填充设备数据。
- callBack:MainCallBack,抽取了MainAbilitySlice中的方法,供其回调使用。
- component:SelectDeviceDialog封装了设备选择弹框,SimplePlayerController封装了视频播放控制栏。
- constants:抽取了使用频繁的常量。
- data:定义了影片数据类。
- proxy:定义了连接远程和调用远程service(PA)的接口、实现类以及代理。
- service:RemoteService为TV端service,供手机端远程调用。
- slice:MainAbilitySlice为TV端默认主界面,RemotInputAbilitySlice为手机端遥控器输入界面,MoviePlayAbilitySlice为视频播放界面。
- utils:封装了公共方法,包括ability管理工具类、日志工具类、搜索工具类、窗口工具类。
- resources:存放工程使用到的资源文件,其中resources\base\layout下存放XML布局文件,resources\base\media下存放图片资源和视频资源。
- config.json:应用的配置文件。
4. 相关权限
本程序开发需要申请以下多端协同multi-device collaboration相关的四个权限,应用权限的申请可以参考权限章节。
1、ohos.permission.DISTRIBUTED_DATASYNC:分布式数据管理权限,允许不同设备间的数据交换
2、ohos.permission.DISTRIBUTED_DEVICE_STATE_CHANGE:监听分布式组网内设备状态变化的权限
3、ohos.permission.GET_DISTRIBUTED_DEVICE_INFO:获取分布式组网内设备列表和设备信息的权限
4、ohos.permission.GET_BUNDLE_INFO:查询其他应用信息的权限
说明:
其中分布式数据管理权限"ohos.permission.DISTRIBUTED_DATASYNC",需要按照动态申请流程向用户申请授权。
5. 界面实现
本章节内容,主要介绍各个界面的布局内容,让您对界面与布局的对应关系有清晰的认识。
TV端默认主界面:展示效果如图1,布局文件为ability_main.xml,位于"resources\base\layout"目录下。文件定义布局使用常用的DirectionalLayout、TableLayout布局,以及TextField、Button、ScrollView和Image等常用组件。您可以通过查看对应的布局文件,了解具体内容。
说明:
使用XML文件定义布局,存在固定的内容和格式。结合运行效果图和文件可快速理解。因此,本章节重点对如何使用代码创建和添加布局进行说明。
设备选择弹框界面:展示效果如图2,弹框界面是结合代码和布局文件动态进行创建的。布局文件包括dialog_select_device.xml和item_device_list.xml,位于"resources\base\layout"目录下。dialog_select_device.xml定义了弹框整体和弹框标题栏部分的布局,item_device_list.xml定义了弹框中设备列表部分的布局。您可以通过查看对应的布局文件,了解具体内容。弹出设备选择框时,应用代码会先查询可供选择的设备,进而对弹框展示内容进行调整。代码片段如下:
private CommonDialog commonDialog;
List deviceInfos = DeviceManager.getDeviceList(DeviceInfo.FLAG_GET_ONLINE_DEVICE);
initView(context, deviceIfs, callBack);
private void initView(Context context, List devices, SelectResultListener listener) {
commonDialog = new CommonDialog(context);
commonDialog.setAlignment(LayoutAlignment.CENTER);
Component dialogLayout = LayoutScatter.getInstance(context)
.parse(ResourceTable.Layout_dialog_select_device, null, false);
commonDialog.setSize(WIDTH, HEIGHT);
commonDialog.setAutoClosable(true);
commonDialog.setContentCustomComponent(dialogLayout);
if (dialogLayout.findComponentById(ResourceTable.Id_list_devices) instanceof ListContainer) {
ListContainer devicesListContainer = (ListContainer) dialogLayout
.findComponentById(ResourceTable.Id_list_devices);
DevicesListAdapter devicesListAdapter = new DevicesListAdapter(devices, context);
devicesListContainer.setItemProvider(devicesListAdapter);
devicesListContainer.setItemClickedListener((listContainer, component, position, ll) -> {
listener.callBack(devices.get(position));
commonDialog.hide();
});
}
dialogLayout.findComponentById(ResourceTable.Id_cancel).setClickedListener(component -> {
commonDialog.hide();
});
}
手机输入遥控界面:展示效果如图3,布局文件为ability_remote_input.xml,位于"resources\base\layout"目录下。文件定义布局使用DirectionalLayout和DependentLayout组件布局,组件则使用了常用组件TextField、Image以及Text。您可以通过查看对应的布局文件,了解具体内容。
视频播放界面:展示效果如图5,布局文件为ability_movie_play.xml文件和simple_player_controller_layout.xml文件,位于"resources\base\layout"目录下。ability_movie_play.xml定义了播放视频的整体界面布局,您可以通过查看文件内容,了解具体实现。在进行视频播放时,代码会为播放界面添加遮罩层。代码片段如下:
SurfaceProvider surfaceView = new SurfaceProvider(this);
DependentLayout.LayoutConfig layoutConfig = new DependentLayout.LayoutConfig();
layoutConfig.addRule(DependentLayout.LayoutConfig.CENTER_IN_PARENT);
surfaceView.setLayoutConfig(layoutConfig);
surfaceView.setVisibility(Component.VISIBLE);
surfaceView.setFocusable(Component.FOCUS_ENABLE);
surfaceView.setTouchFocusable(true);
surfaceView.requestFocus();
surfaceView.pinToZTop(false);
surfaceView.getSurfaceOps().get().addCallback(mSurfaceCallback);
if (findComponentById(ResourceTable.Id_parent_layout) instanceof DependentLayout) {
DependentLayout dependentLayout = (DependentLayout)
findComponentById(ResourceTable.Id_parent_layout);
SimplePlayerController simplePlayerController = new SimplePlayerController(this, player);
dependentLayout.addComponent(surfaceView);
dependentLayout.addComponent(simplePlayerController);
}
simple_player_controller_layout.xml播放界面控制栏的布局。您可以通过查看文件内容,了解具体实现。播放界面控制栏的初始化、播放暂停图标切换,相关代码片段如下:
private void initView() {
Component playerController = LayoutScatter.getInstance(mContext).parse(ResourceTable.Layout_simple_player_controller_layout, null, false);
addComponent(playerController);
if (findComponentById(ResourceTable.Id_play_controller) instanceof Image) {
playToggle = (Image) findComponentById(ResourceTable.Id_play_controller);
}
}
private void initListener() {
playToggle.setClickedListener(component -> {
if (controllerPlayer.isNowPlaying()) {
controllerPlayer.pause();
playToggle.setPixelMap(ResourceTable.Media_video_play);
} else {
controllerPlayer.play();
playToggle.setPixelMap(ResourceTable.Media_video_stop);
}
});
}
6. 设备连接
点击TV端主界面搜索框,会出现选择手机遥控器的弹框(图2)。用户点击需要的手机遥控设备,点击后对应的手机设备会随即拉起手机遥控输入界面。拉起手机遥控输入界面的核心代码如下:
public void openRemoteAbility(String deviceId, String bundleName, String abilityName) {
Intent intent = new Intent();
String localDeviceId = KvManagerFactory.getInstance()
.createKvManager(new KvManagerConfig(abilitySlice)).getLocalDeviceInfo().getId();
intent.setParam("localDeviceId", localDeviceId);
Operation operation = new Intent.OperationBuilder()
.withDeviceId(deviceId)
.withBundleName(bundleName)
.withAbilityName(abilityName)
.withFlags(Intent.FLAG_ABILITYSLICE_MULTI_DEVICE)
.build();
intent.setOperation(operation);
abilitySlice.startAbility(intent);
}
其中"localDeviceId "为TV设备id,作为拉起手机端FA的参数,传递至遥控界面,用于遥控界面与对应的TV设备配对时使用。遥控界面FA在启动过程中,通过initConnManager()方法,与TV端RemoteService建立连接。与RemoteService建立连接的核心代码如下:
public void connectPa(Context context, String deviceId) {
if (deviceId != null && !deviceId.trim().isEmpty()) {
Intent connectPaIntent = new Intent();
Operation operation = new Intent.OperationBuilder()
.withDeviceId(deviceId)
.withBundleName(context.getBundleName())
.withAbilityName(RemoteService.class.getName())
.withFlags(Intent.FLAG_ABILITYSLICE_MULTI_DEVICE)
.build();
connectPaIntent.setOperation(operation);
conn = new IAbilityConnection() {
@Override
public void onAbilityConnectDone(ElementName elementName, IRemoteObject remote, int resultCode) {
LogUtils.info(TAG, "===connectRemoteAbility done");
proxy = new MyRemoteProxy(remote);
}
@Override
public void onAbilityDisconnectDone(ElementName elementName, int resultCode) {
LogUtils.info(TAG, "onAbilityDisconnectDone......");
proxy = null;
}
};
context.connectAbility(connectPaIntent, conn);
}
}
成功建立连接后,就可以通过遥控界面对应按钮进行输入、搜索和移动等操作了。
说明:
使用多设备进行分布式应用测试时,请先检查使用的设备已完成网络配对。检查步骤如下:
所使用设备均以相同的华为帐号登录;
所使用设备接入相同Wi-Fi热点或者各设备间已完成蓝牙配对操作;
查看各设备的"设置 > 超级终端"界面,确认"我的在线设备"中包含所使用的设备。
7. 远端控制和播放
手机端的输入法应用被拉起,在初始化页面时,会绑定相关点击事件。手机端应用对TV端能做出的控制有:向上移动、向下移动、向左移动、向右移动、确定、返回、关闭。
private void initListener() {
// 监听文本变化,远程同步
textField.addTextObserver((ss, ii, i1, i2) -> {
Map<String, String> map = new HashMap<>(INIT_SIZE);
map.put("inputString", ss);
connectManager.sendRequest(ConnectManagerIml.REQUEST_SEND_DATA, map);
});
okButton.setClickedListener(component -> {
// 点击OK按钮
buttonClickSound();
String searchString = textField.getText();
Map<String, String> map = new HashMap<>(INIT_SIZE);
map.put("inputString", searchString);
connectManager.sendRequest(ConnectManagerIml.REQUEST_SEND_SEARCH, map);
});
leftButton.setClickedListener(component -> {
// 点击左键按钮
sendMoveRequest(Constants.MOVE_LEFT);
});
rightButton.setClickedListener(component -> {
// 点击右键按钮
sendMoveRequest(Constants.MOVE_RIGHT);
});
upButton.setClickedListener(component -> {
// 点击向上按钮
sendMoveRequest(Constants.MOVE_UP);
});
downButton.setClickedListener(component -> {
// 点击向下按钮
sendMoveRequest(Constants.MOVE_DOWN);
});
goBackButton.setClickedListener(component -> {
// 返回TV主页
sendMoveRequest(Constants.GO_BACK);
});
closeButton.setClickedListener(component -> {
// 返回主页
sendMoveRequest(Constants.GO_BACK);
terminateAbility();
});
}
基于分布式任务调度和公共事件,将远端移动操作请求发送给TV端。
private void sendMoveRequest(String direction) {
buttonClickSound();
Map<String, String> map = new HashMap<>(INIT_SIZE);
map.put("move", direction);
connectManager.sendRequest(ConnectManagerIml.REQUEST_SEND_MOVE, map);
}
TV端收到公共事件,做出相应处理。如果收到的事件为文本输入,则在TV搜索框填充输入内容;如果收到的事件操作判断为移动,则根据具体移动方向移动焦点;如果收到的事件操作为确定,判断当前焦点所在位置,焦点在搜索框,则执行搜索逻辑,焦点在影片图片组件上,则播放影片;如果收到的事件操作为返回,则返回TV主页,焦点聚焦搜索框
class MyCommonEventSubscriber extends CommonEventSubscriber {
MyCommonEventSubscriber(CommonEventSubscribeInfo info) {
super(info);
}
@Override
public void onReceiveEvent(CommonEventData commonEventData) {
Intent intent = commonEventData.getIntent();
int requestType = intent.getIntParam("requestType", 0);
String inputString = intent.getStringParam("inputString");
if (requestType == ConnectManagerIml.REQUEST_SEND_DATA) {
tvTextInput.setText(inputString);
} else if (requestType == ConnectManagerIml.REQUEST_SEND_SEARCH) {
if (componentPointDataNow.getPointX() == 0) {
// 调用TV端的搜索方法
searchMovies(tvTextInput.getText());
return;
}
// 播放影片
abilityMgr.playMovie(getBundleName(), MOVIE_PLAY_ABILITY);
} else {
// 移动方向
String moveString = intent.getStringParam("move");
MainCallBack.movePoint(MainAbilitySlice.this, moveString);
}
}
}
焦点移动方法实现。连续点击方向键可能会产生并发,因此该方法加上锁;清空上一个焦点的聚焦效果,新的焦点设置聚焦效果。
public void move(int pointX, int pointY) {
MOVE_LOCK.lock();
try {
// 设置焦点滚动
if (pointX == 0 && componentPointDataNow.getPointX() > 0) {
scrollView.fluentScrollByY(pointY * size.height);
}
if (componentPointDataNow.getPointX() == 0 && pointX == 1) {
scrollView.scrollTo(0, 0);
}
// 设置背景
if (componentPointDataNow.getPointX() + pointX == 0) {
setBackGround(componentPointDataNow.getPointX() + pointX, 1);
} else {
setBackGround(componentPointDataNow.getPointX() + pointX, componentPointDataNow.getPointY() + pointY);
}
} finally {
MOVE_LOCK.unlock();
}
}
搜索方法实现。清空上次搜索结果及背景效果,设置新的搜索结果和背景效果。
private void searchMovies(String text) {
if (text == null || "".equals(text)) {
return;
}
// 清空上次搜索结果及背景效果
clearHistroyBackGround();
for (ComponentPointData componentPointData : ComponentPointDataMgr.getComponentPointDataMgrs()) {
if (MovieSearchUtils.isContainMovie(componentPointData.getMovieName(), text)
|| MovieSearchUtils.isContainMovie(componentPointData.getMovieFirstName(), text)) {
movieSearchList.add(componentPointData);
Component component = findComponentById(componentPointData.getComponentId());
component.setPadding(SEARCH_PADDING, SEARCH_PADDING, SEARCH_PADDING, SEARCH_PADDING);
}
}
if (movieSearchList.size() > 0) {
componentPointDataNow = movieSearchList.get(0);
Component component = findComponentById(componentPointDataNow.getComponentId());
component.setPadding(FOCUS_PADDING, FOCUS_PADDING, FOCUS_PADDING, FOCUS_PADDING);
} else {
Component component = findComponentById(componentPointDataNow.getComponentId());
component.requestFocus();
}
}
8. 回顾和总结
本篇codelab中我们详细介绍了分布式遥控器的设备连接、远程操控等内容,直观的展现了HarmonyOS的分布式特性。
通过本篇codelab,您可以学会如何拉起分布式应用,如何收发公共事件,如何实现分布式任务调度;另外,您还可以了解UI布局和视频播放功能的相关知识点。
在分布式应用开发中,您需要重点学习和掌握HarmonyOS的跨设备协同、分布式任务调度和公共事件通知的特性能力。
9. 参考
10. 恭喜您
目前您已经成功完成了分布式输入法并且学到了:
- 常用布局的使用
- 分布式任务调度
- 公共事件与通知