?? ? ? 2021年7月31日,我在杭州HarmonyOS开发者日做了一个分享,主题是关于鸿蒙服务卡片的奇妙用法。通过让多张服务卡片之间相互交互来实现一个类似“连连看”的游戏(项目名称是“找我”),而且还支持分布式,可以让多部鸿蒙设备参与进来。在过去的一段时间,经常有小伙伴私信我,问能否讲解一下这款游戏的实现原理。现在我就借这篇文章的机会,来谈一谈这款基于鸿蒙服务卡片的分布式游戏的实现原理。
1. 项目概述
游戏演示请看下面的视频
????????“找我”游戏包含两个服务卡片,尺寸分别是2×4和1×2。其中2 ×4的服务卡片用于控制游戏(相当于游戏面板)、1×2的服务卡片用于玩游戏。游戏面板卡片只能在桌面上放一个(就算放置多个,也只有第一个起作用),1×2的服务卡片用于玩游戏,可以在桌面上放置1个或多个。每一个1×2的服务卡片被分成左右两部分,分别用来显示两个随机字符,而且随机字符的颜色和背景色也是随机的,如下图所示。
?????????在游戏控制面板的左侧也显示一个随机字符。用户可以单击1×2的服务卡片左侧或右侧。如果被单击的随机字符与游戏控制面板上的随机字符是否相同,在游戏控制卡片上的得分就会加5分(可以设置积分增量)。当游戏控制面板右侧的倒计时为零时游戏结束,并将游戏最终的积分和相关的数据保存到数据库中,可以查看不同用户的游戏积分,如下图所示。
?????????如果点击游戏控制面板右侧的扩展按钮,会弹出设备列表,点击某一个设备,将该窗口流转到另外一部鸿蒙的设备,同时,两部鸿蒙设备已经连接,如下图所示。
?这时两部鸿蒙设备可以同时玩游戏,如下图所示。
?
?????????加入的鸿蒙设备越多,难度越大。而且需要脑袋来回转动寻找相同的字符,所以这款游戏对颈椎相当有好处。
2. 服务卡片的布局
????????在游戏中有两个服务卡片,他们的布局都需要使用CSS和HML来实现,例如,游戏控制卡片的布局代码如下:
<div >
<div class="normal_container">
<div class="pic_title_container" onclick="settings">
<div style="flex-direction : row;">
<text style="text-align : center; width : 30%; font-size : 60px; color : brown;">{{ randomChar}}</text>
<div style="flex-direction : column; width : 40%; margin-top: 20px;">
<text style="text-align : center; width : 100%; font-size : 25px;">
得分
</text>
<text style="text-align : center; width : 100%; font-size : 25px;color: blue;">
{{ score }}
</text>
</div>
<text style="text-align : center; font-size:60px; width : 30%;color: darkmagenta;">{{countDown}}</text>
</div>
<div style="margin-right : 10px;">
<button onclick="start" type="capsule" style="opacity: 0.7; margin-right : 10px; text-align : center; width : 33%;">开始</button>
<button onclick="stop" type="capsule" style="opacity: 0.7; margin-right : 10px; text-align : center; width : 33%;">停止</button>
<button onclick="extend" type="capsule" style="opacity: 0.7;text-align : center; width : 33%;">扩展</button>
</div>
</div>
</div>
</div>
?
????????这段布局代码与html非常类似。整段代码分成两部分,上半部分是游戏信息显示界面,下半部分是3个按钮。而且在这段布局代码中包含了大量的变量,如 {{ score }}、{{ randomChar}}等。这些变量都需要用Java代码进行设置。
3. 如何高频刷新服务卡片
????????在默认的情况下,服务卡片的定时刷新时间最短是30分钟(需要是30分钟的整数倍)。但这个游戏要求以秒为单位刷新,所以我们需要使用其他的方式定时刷新服务卡片。可以使用线程或者是定时器进行刷新,这款游戏使用了线程来刷新服务卡片。
????????例如下面的代码创建了一个线程对象gameThread。在线程对象的run方法中通过休眠的方式定时刷新服务卡片。在本例中,每2秒刷新一次(使用updateForm方法刷新服务卡片)。
Thread gameThread = new Thread(new Runnable() {
// 延迟放到最后
@Override
public void run() {
// 刷新服务卡片
while (true) {
try {
Thread.sleep(50);
if (startFlag) {
... ...
// 刷新服务卡片,产生随机字符
updateForm(gameWidgetFormId, formBindingData);
}
Thread.sleep(2000); // 没2秒刷新一次
}
} catch (Exception e) {
}
}
}
});
gameThread.start();
4. 多张服务卡片如何交互
????????在本例中需要多张服务卡片进行交互。也就是通过控制游戏的服务卡片来更新用于玩游戏的服务卡片。一个服务卡片要想控制其他的服务卡片,首先需要获得这些服务卡片的FormId。每一个服务卡片都拥有唯一的FormID。
????????以首先需要在onCreateForm方法中保存这些服务卡片的FormID,代码如下:
// 用于保持服务卡片的相关信息
public static class GameWidgetData {
public String leftValue = "4";
public String rightValue = "6";
public String leftBackgroundColor = "#FF0000";
public String rightBackgroundColor = "#FF00FF";
public String leftColor = "#FF00FF";
public String rightColor = "#FF0000";
}
public static Map<Long, GameWidgetData> gameWidgetFormIds = new HashMap<>();
@Override
protected ProviderFormInfo onCreateForm(Intent intent) {
... ...
if (formName.equals("GameWidget")) {
GameWidgetData gameWidgetData = new GameWidgetData();
gameWidgetFormIds.put(formId, gameWidgetData);
}
... ...
return formController.bindFormData();
}
????????在这段代码中,使用了gameWidgetFormIds来保存所有1×2服务卡片的FormId,其中GameWidgetFormIds类用于保持与1*2服务卡片相关的数据,如字符颜色、背景色等。通过服务卡片的FormId,可以获取与该服务卡片相关的信息。
????????不过光在onCreateForm里保存FormID还不行。因为,onCreateForm方法并不是将服务卡片放到桌面上时调用的,而是在显示服务卡片列表时调用的,看下面的图。在这张图中展示了日历应用中所有的服务卡片。其实在这时onCreateForm方法已经被调用了,而且是被调用了多次。
????????实际上,日历应用里有4个服务卡片,分别是4个尺寸(1×2、2×2、2×4和4×4)。所以onCreateForm方法被调用了4次。也就是说,App中有n张服务卡片,那么onCreateForm方法就会被调用n次。
?????????不过不管App中有多少张服务卡片,一次只能将1张服务卡片放到桌面上,所以要获得放在桌面上的服务卡片的FormId,还需要刨除其他n-1张服务卡片的ID。因此,需要在onDeleteForm方法中删除其他n-1张服务卡片的FormID,代码如下:
// formId是被删除的服务卡片的id
@Override
protected void onDeleteForm(long formId) {
if (gamePanelFormId == formId) {
gamePanelFormId = 0;
} else { // 移除多余的服务卡片
gameWidgetFormIds.remove(formId);
}
}
也就是说,如果App中有n张服务卡片,将某一张服务卡片放到桌面上,那么会调用2n - 1次事件方法。其中n次是onCreateForm,另外n - 1次是onDeleteForm。
这里还要提一下onDeleteForm方法。该方法有如下两种情况会被调用:
(1)将服务卡片放到桌面之前(前面介绍的场景)
(2)从桌面上删除服务卡片
5. 实现分布式服务卡片
实现分布式服务卡片需要如下3步:
(1)发现其他鸿蒙设备
(2)连接鸿蒙设备
(3)鸿蒙设备之间交互数据
(1)发现其他鸿蒙设备
????????发现鸿蒙设备有多种方式,本例使用了鸿蒙特有的分布式技术,就是发现其他设备的DeviceID,每一个鸿蒙设备都有唯一的DeviceID。获取其他设备的DeviceID以及相关信息,使用下面一行代码即可。getDeviceList方法会返回List类型的值,保存发现的所有鸿蒙设备的信息。
DeviceManager.getDeviceList(DeviceInfo.FLAG_GET_ALL_DEVICE);
(2)连接鸿蒙设备
????????这里的连接是指望网络连接,本例使用socket连接两部鸿蒙设备。因为Socket处理高频数据传输比较有优势。在第一步发现鸿蒙设备后,通过FA流转,将发起流转的鸿蒙设备的IP通过onSaveData方法传给另外一部鸿蒙设备,代码如下:
@Override
public boolean onSaveData(IntentParams intentParams) {
intentParams.setParam("ip", Tools.getLocalIP(this));
return true;
}
getLocalIP方法用于获取本地IP,代码如下:
// 获取本地IP
public static String getLocalIP(Context context) {
try {
int ip = WifiDevice.getInstance(context).getIpInfo().get().getIpAddress() ;
String ipStr =
String.format("%d.%d.%d.%d",
(ip & 0xff),
(ip >> 8 & 0xff),
(ip >> 16 & 0xff),
(ip >> 24 & 0xff));
return ipStr;
} catch (Exception e) {
System.out.println("socket error:" +e.getMessage());
}
return "";
}
????????假设发起FA流转的鸿蒙设备为A,FA流转的目标鸿蒙设备为B。这时B已经获取了A的IP。A端需要启动Socket服务,等待B端的连接,代码如下:
// 在A端调用startServer方法启动Socket服务
public void startServer() {
ServerSocket serverSocket = new ServerSocket(8888);
if (thread == null) {
thread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
try {
final Socket socket = serverSocket.accept();
// 等待B的连接
}catch (Exception e) {
}
} catch (Exception e) {
}
}
}
});
thread.start();
}
}
????????B端在接收到A的IP后,会在onRestoreData方法中获取A的IP,并通过Socket连接到A,代码如下:
public boolean onRestoreData(IntentParams intentParams) {
// 获取A的IP
final String ip = intentParams.getParam("ip").toString();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
// 连接A
clientSocket = new Socket(ip, 8888);
//获取输入输出流,与A交互
InputStream is = clientSocket.getInputStream();
OutputStream os = clientSocket.getOutputStream();
} catch (Exception e) {
}
}
});
thread.start();
return true;
}
(3)鸿蒙设备之间交互数据
????????经过前两步后,A和B已经建立了Socket数据线路,剩下的事情就简单得多了。首先A会同时为A和B产生随字符,然后从A端将随机字符传送到B端,这时B会将传输过来的随机字符显示在1×2的服务卡片上,就会看到本文一开始的效果。然后B端点击某一个卡片,会自己判断点击结果,如果点击正确,会通知A端加分。
6. 保存游戏记录
????????如果想要游戏有更好的可玩性,可以将游戏中所产生的数据保存起来。本例将游戏所产生的积分保存在SQLite数据库中,以便可以查询游戏积分。保存积分数据的核心代码如下:
package com.unitymarvel.harmonyos.projects.findme.common;
import ohos.app.Context;
import ohos.data.DatabaseHelper;
import ohos.data.rdb.RdbOpenCallback;
import ohos.data.rdb.RdbStore;
import ohos.data.rdb.StoreConfig;
import ohos.data.resultset.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class DataService {
private Context context;
private RdbStore store;
public DataService(Context context) {
this.context = context;
StoreConfig config = StoreConfig.newDefaultConfig("game.sqlite");
RdbOpenCallback callback = new RdbOpenCallback() {
// 创建表时调用
@Override
public void onCreate(RdbStore store) {
// 创建t_users表
store.executeSql("CREATE TABLE IF NOT EXISTS t_records (id INTEGER PRIMARY KEY autoincrement, user VARCHAR(30), score int, time datetime default (datetime('now', 'localtime')))");
Tools.print("成功创建t_records表");
}
// 升级表时调用
@Override
public void onUpgrade(RdbStore store, int oldVersion, int newVersion) {
}
};
DatabaseHelper helper = new DatabaseHelper(context);
store = helper.getRdbStore(config,
1,
callback,
null);
}
// 保存积分数据
public void writeGameRecord(String user, int score) {
String insertSQL = "insert into t_records(user, score) values(?,?);";
// 向t_users表中插入3条记录
store.executeSql(insertSQL, new Object[]{user, score});
}
// 获取积分数据
public List<GameRecord> getGameRecords() {
ArrayList<GameRecord> result = new ArrayList<>();
String selectSQL = "select user, score, time from t_records order by score desc, time, user";
ResultSet resultSet = store.querySql(selectSQL, null);
while(resultSet.goToNextRow()) {
GameRecord record = new GameRecord();
record.user = resultSet.getString(0);
record.score = resultSet.getInt(1);
record.time = resultSet.getString(2);
result.add(record);
}
return result;
}
}
????????上面的代码将建立一个名为game.sqlite的SQLite数据库文件,并创建一个t_records表,每次游戏结束(倒计时为0),会将游戏积分和用户名保存在t_records表中。并通过getGameRecords方法获取所有用户的游戏积分数据,并可以通过这些数据显示本文一开始展示的游戏积分列表。
????????到现在为止,已经深度剖析了“找我”的核心实现原理,其中涉及到了大量鸿蒙的技术,如服务卡片、FA流转、数据库等。在开发类似应用之前,需要先掌握这些技术。
|