[开发者心得] 详解数据中心同步时序问题

[复制链接]
103 |5
六安🐟片 发表于 2024-10-29 11:17:58 | 显示全部楼层 |阅读模式
本帖最后由 六安🐟片 于 2024-10-29 17:31 编辑


pexels-markusspiske-1089438.png

最近收到很多开发者对于模块的数据控制类(Subdata & DataCenter)的疑问:
在数据同步时,如何在客户端得知数据已完成同步,并获取到同步后的数据?

接下来,会通过4个案例来介绍存档的同步方法,相信大家看完会更高效的使用数据类进行数据的存储和同步。

示例代码

数据类
在数据类中,定义了四个单独的数据,并定义了一个代理事件onParam2Changed

export class TestDataHelper extends Subdata {
    /** 数据1 */
    @Decorator.persistence()
    param1: number;

    /** 数据2 */
    @Decorator.persistence()
    param2: number;

    /** 数据3 */
    @Decorator.persistence()
    param3: number;

    /** 数据4 */
    @Decorator.persistence()
    param4: number;

    /** 数据2变化的代理事件 */
    onParam2Changed: Action1<number> = new Action1();

    /** 初始化数据 */
    protected onDataInit(): void {
        this.param1 = 0;
        this.param2 = 0;
        this.param3 = 0;
        this.param4 = 0;
    }
}

客户端模块
在客户端模块中,监听了数据类的onDataChange以及onParam2Changed事件,并定义了按下键盘按钮1-4时通知服务端修改对应的数据。

export class TestModuleC extends ModuleC<TestModuleS, TestDataHelper> {
    protected onStart(): void {
        // 监听数据的onDataChange变化,每次数据在服务端执行save(true)进行同步时会触发回调
        this.data.onDataChange.add(() => {
            console.log("onDataChanged", this.data.param1, this.data.param2, this.data.param3);
        })

        // 这是在数据类中自己定义的一个代理,当在服务端执行save(true)后,需要自己触发调用,客户端会收到回调
        this.data.onParam2Changed.add((info) => {
            console.log("onParam2Changed", "info:" + info, "data:", this.data.param2);
        })

        // 按1时,会请求服务端修改param1的值,并打印当前客户端的param1的值
        InputUtil.onKeyDown(Keys.One, () => {
            this.server.net_changeParams1(this.localPlayerId);
            console.log("onKey One Down", this.data.param1);
        })

        // 按2时,会请求服务端修改param2的值,并打印当前客户端的param2的值
        InputUtil.onKeyDown(Keys.Two, () => {
            this.server.net_changeParams2(this.localPlayerId);
            console.log("onKey Two Down", this.data.param2);
        })

        // 按3时,会请求服务端修改param3的值,并等待服务端返回后打印当前客户端的param3的值
        InputUtil.onKeyDown(Keys.Three, async () => {
            await this.server.net_changeParams3(this.localPlayerId);
            console.log("onKey Three Down", this.data.param3);
        })

        // 按4时,会请求服务端修改param4的值,并同步修改本地param4的值,然后打印当前客户端的param4的值
        InputUtil.onKeyDown(Keys.Four, () => {
            this.server.net_changeParams4(this.localPlayerId);
            this.data.param4 = this.data.param4 + 1;
            console.log("onKey Four Down", this.data.param4);
        })
    }
}

服务端模块
在服务端模块中,对于四个不同的数据,定义了4种不同的修改及同步的方法。

export class TestModuleS extends ModuleS<TestModuleC, TestDataHelper> {

    /** 修改玩家param1的值,保存并同步到客户端 */
    net_changeParams1(playerId: number) {
        const playerData = this.getPlayerData(playerId);
        playerData.param1 = playerData.param1 + 1;
        playerData.save(true);
    }

    /** 修改玩家param2的值,保存并同步到客户端,然后手动触发onParam2Changed代理事件 */
    net_changeParams2(playerId: number) {
        const playerData = this.getPlayerData(playerId);
        playerData.param2 = playerData.param2 + 1;
        playerData.save(true);
        playerData.onParam2Changed.call(playerData.param2);
    }

    /** 修改玩家param3的值,保存并同步到客户端 */
    net_changeParams3(playerId: number) {
        const playerData = this.getPlayerData(playerId);
        playerData.param3 = playerData.param3 + 1;
        playerData.save(true);
    }

    /** 修改玩家param4的值,仅保存,不同步到客户端 */
    net_changeParams4(playerId: number) {
        const playerData = this.getPlayerData(playerId);
        playerData.param4 = playerData.param4 + 1;
        playerData.save(false);
    }
}

四种获取数据同步事件的方法
1. 监听数据类的onDataChange事件
继承自Subdata的数据类都会带有一个onDataChange的事件。这个事件会在每次服务端执行save(true)进行同步时,在客户端触发回调。可以监听这个事件并在回调中处理对应数据。
以上面的代码为例,当按下“1”时,会将param1的值修改为1,并执行保存&同步的操作。客户端的打印结果如下:
img_v3_02g4_698cf2c3-3354-46e0-a328-15945a7e3b7g.jpg
可以看到,客户端执行结束服务端修改数据请求后马上打印的本地数据没有变化,还是0,这是因为此时服务端的数据还没有同步到客户端。又因为我们监听了onDataChange事件,所以在数据同步回调中,我们打印本地数据,发现本地数据的param1已经变为了1.

优点:数据类自带,无需做额外定义。
缺点:当我们在一个数据类中定义了非常多数据时,每一个数据的修改同步都会调用onDataChange事件,我们需要在事件回调中判断具体哪个数据发生了改变再执行相应逻辑,这时就会比较复杂。所以推荐在数据类的结构比较简单的情况使用,或者需要对数据类的整体数据变化做出处理的情况来使用。

2. 自定义数据变化的委托事件
我们可以在数据类中自定义委托事件,如上面代码中的onParam2Changed。这个事件需要在服务端执行save(true)进行保存同步后,自己手动触发调用,之后客户端就会收到回调。
以上面的代码为例,当按下“2”时,会将param2的值修改为1,并执行保存&同步的操作,然后手动调用onParam2Changed客户端的打印结果如下:
img_v3_02g4_b01c38f9-9b5f-4d6e-a934-c0f5fffcbdag.jpg
同样,客户端执行结束服务端修改数据请求后马上打印的本地数据没有变化,还是0,这是因为此时服务端的数据还没有同步到客户端。
onDataChange事件中,正常获取到了同步后的数据。
此外,在监听了自己定义的onParam2Changed事件中,无论是通过参数还是直接访问本地数据,都获得了同步后的数据。

优点:可以针对单一数据的改变自定义同步事件,可以灵活配合项目的需求进行使用。
缺点:需要自己定义委托事件,比较麻烦。当有多个需要分别监听变化的数据时,需要定义多个委托。同时,每个委托同步的时候相当于一次rpc,调用频繁也会增加带宽压力。

3. 使用异步方法等待服务端同步完成
如示例中第三个方法,可以异步等待服务端执行完成后返回。由于服务端已经执行了save(true), 代表数据已经同步,等客户端收到服务端返回值时,客户端本地的数据已经是同步后的数据了。客户端的打印结果如下:
img_v3_02g4_fb10a701-f275-426e-afbf-c365f64643dg.jpg
onDataChange事件中,正常获取到了同步后的数据。同时,由于在异步等待服务端执行完成返回后才打印的“onKey Three Down”,此时获取本地数据是同步后的数据。

优点: 不用定义代理事件。可以在一个方法内处理相应逻辑。
缺点:需要修改为异步方法,需要注意方法的返回值类型和相对应的逻辑时序。同时,获取net_方法的返回值也是一次rpc,频繁调用也会增加带宽压力,需要合理使用。如果存在不需要返回值的net_函数,最好加上@Decorator.noReply()修饰符,来减少一次服务端向客户端的rpc。

4. 同步在客户端和服务端修改数据,不执行rpc同步
对于需要高频修改的数据,推荐在客户端和服务端的数据类中一起修改数据,这样双端的数据内容是一致的,但避免了服务端向客户端的同步,可以减轻带宽同步压力。
以上面的代码为例,当按下“4”时,会将param4的值修改为1,服务端仅执行保存,不同步,客户端在请求服务端方法后,在本地修改param4的值客户端的打印结果如下:
img_v3_02g4_79de3d39-4148-4fdd-b9d2-6de76048c9cg.jpg
由于服务端没有进行数据同步,所以没有触发onDataChange委托回调。同时,由于提前在客户端本地也进行了数据修改,所以此时获取本地的数据也是修改后的数据。

优点:能够优化数据同步rpc带来的带宽占用。
缺点:需要自己来保证客户端和服务端数据的一致性,对代码的逻辑要求比较高,同时,需要有相应的校验手段,防止玩家通过修改内存数据等手段进行作弊。
回复

使用道具 举报

叽里咕噜小胡桃 发表于 2024-10-29 17:59:25 | 显示全部楼层
非常棒的经验分享! 最近正好在看模块管理这一块的教程~ 数据同步确实是很重要的一个环节~
回复

使用道具 举报

思想的鱼(求关注) 发表于 2024-10-29 18:24:19 | 显示全部楼层
我在项目中没有使用到模块管理,但是使用到了数据中心。

如果是想增加数据或者修改数据。会进行如下的操作:
操作1:一般这样操作数据改变了,前台也显示变更了。
1.先在服务执行更新数据
const playerData = DataCenterS.getData(player, PlayerData);// 脚本A
const gold = playerData.GOLD + 100; // 获取
playerData.gold = gold;
this.save(true);

const playerData = DataCenterS.getData(player, PlayerData); // 脚本B
const  Gold = playerData.GOLD; // 获取
Event.dispatchToClient(player, "c_addGold", Gold); // 告诉客户端去修改UI
操作2:先把数据进行计算,然后替换。
const playerData = DataCenterS.getData(player, PlayerData);
const  Gold = playerData.GOLD; // 获取
let total = Gold + 100;
playerData.gold = total;
this.save(true);
Event.dispatchToClient(player, "c_addGold", total ); // 告诉客户端去修改UI

目前使用这样的操作均可以正常显示和存储,有哪些弊端需要调整呢。

我是想要个最省事的方法,你提供的四个方法里,我挺认可3异步的这种,但是也不太想频繁用,怕异常会影响后续的逻辑执行。
回复

使用道具 举报

思想的鱼(求关注) 发表于 2024-10-29 18:41:28 | 显示全部楼层
我写了一些rpc,数据变更更方便一些。

比如,我希望给玩家增加金币,我可以这样操作

// 考虑到玩家会用外挂修改数据,核心数据一般都是在服务端进行发送请求的
Event.dispatchToLocal("numberAddValue", player, "GOLD", value);


// 非核心数据,可以由客户端发给服务端

Event.dispatchToServer(
      "setNumberValue",
      "MUSIC_VOLUME",
      volume,
    );



image.png
image.png


回复

使用道具 举报

六安🐟片楼主 发表于 2024-10-29 18:46:30 | 显示全部楼层
本帖最后由 六安🐟片 于 2024-10-29 18:48 编辑
思想的鱼(求关注) 发表于 2024-10-29 18:24
我在项目中没有使用到模块管理,但是使用到了数据中心。

如果是想增加数据或者修改数据。会进行如下的操作 ...
这种事件通知的写法没什么问题,做好事件管理就行。


另外可以使用第二种方法,在PlayerData中定义一个数据变化的事件,在UI等需要获取数据变化的地方监听数据变化。
例如有一个coinUI,可以在这个ui脚本的onStart中添加监听:


onStart() {
        DataCenterC.ready().then(()=>{
            const data = DataCenterC.getData(PlayerData);
            data.onGoldChanged.add(()=>{this.goldText.text = String(data.GOLD)})
        })
    }

回复

使用道具 举报

思想的鱼(求关注) 发表于 2024-10-29 22:48:31 | 显示全部楼层
六安🐟片 发表于 2024-10-29 18:46
这种事件通知的写法没什么问题,做好事件管理就行。

收到,感觉这样的确是最佳的,比我自己写的更优。而且代码解耦,更容易理解。
回复

使用道具 举报

热门版块
快速回复 返回顶部 返回列表