在过往做过的绝大多数可视化项目,为了高效,我们都会选择不同的前端框架来进行开发,最常见的就是react和vue,其中vue偏多,因为vue的成本可能会更低,vue的好处不是今天主要讨论的点,框架的好处可以在2D页面开发时显现优势,但是在大型的三维场景开发过程中,框架对业务本身并没有太大的帮助。
如图所示的项目,底部会有很多的菜单,可能会有二级,三级,或者某个主题中还会有一些图例,在绝大多数项目中,一个主题或者一个图例之间的切换,三维中的功能也要做创建或销毁的操作,在vue项目中,组件销毁我们可以执行对应的生命周期去做一系列操作,但是在可视化项目中的业务菜单是不存在组件销毁的,一般我们只能绑定或解绑事件,长期从事数字孪生类项目的同学就知道,这到底有多痛苦,这还只是点击2D部分,三维呈现业务的,还有一些主题特有的三维操作的业务逻辑,一套销毁执行,监听等等等。。。。项目做完代码就成屎山了,代码复用低,修改起来更是困难。
UE项目的开发,我们一般会选择视频流加操作流来搞定,像在亚运会这种规模比较大的项目中,为了提升开发效率,2D业务和3D业务都是分开去做的,然后2D通信给UE完成业务逻辑交互,两个团队,同时进行,然后联调,最最麻烦的是,项目中有时间轴
时间轴的大致业务逻辑是,当切换到某个主题,页面上被激活的逻辑,要按照时间轴回放,比如我切到馆外态势的一个主题,该主题展示了馆外人流的热力图,和人流top5的柱子,当人拖动了时间轴,页面上的热力图和柱子就会取到时间轴的时刻进行渲染,关闭打开,都要遵循回放的逻辑。听着好像没什么,实际上手操作,实在麻烦。
那么讲了这么多,有没有好的解决方案呢,接下来,我们就进入正题
首先来说说事件树
{
name: '首页',
themeInfo: {},
methods: [
{
name: '切换首页',
isActive: true,
action: () => {},
resetAction: firstPageResetAction,
},
],
theme: [
{
name: '首页主题',
isActive: true,
loop: true,
themeInfo: {},
classCt: homePage,
init: ['漫游'
],
methods: [
{
name: '漫游',
isActive: false,
noTime: true,
action: 'roam',
resetAction: 'stopRoam'
}
]
}
]
},
{
name: '网络态势',
themeInfo: {},
methods: [
{
name: '切换地球',
isActive: true,
action: networkEvent,
resetAction: networkReset,
},
],
theme: [
{
name: '保障范围及资源',
isActive: true,
loop: true,
themeInfo: {},
classCt: coverResourceInstance,
init: ['气泡'
],
methods: [
{
name: '柱子',
isActive: false,
noTime: true,
action: 'createCoverResourceColumn',
resetAction: 'destroyCoverResource',
exclusion: ['气泡'],
param: {
name: 'baseStation'
}
},
{
name: '气泡',
isActive: true,
noTime: true,
action: 'createCover',
resetAction: 'destroyCover',
exclusion: ['柱子'],
}
],
},
],
}
按照主题,嵌套的一个json,主题上绑定着这个主题的方法类即classCt,底下的methods上挂载着这个主题所需要的所有方法,isActive是否进入主题被激活,noTime是否受时间轴影响,action触发的函数,resetAction销毁的方法,exclusion与哪个业务逻辑之间存在互斥关系。param代表进入该主题是否需要某些特定的参数,当然这些都要按照项目的需求来设置。每一个主题的类都采用了单例模式,有些人会讲单例不好,但我就是要做解耦,而且主题业务之间代码要独立且清晰,也为了防止客户变更需求。每个类中 都会存在一个init()函数和reset()函数,这两个函数模拟了两个生命周期,创建和销毁,事件派发的业务逻辑中会自动去执行上一个类中的销毁。
{
"type": "menu",
"action": "eventThing",
"params": {
"methods": "hotMap",
"data": {
"type": "voice---left---tab1---chart1"
}
}
}
这是项目中的一段2D页面通过postmessage传递给UE的交互数据, 页面上所有的操作事件按钮触发的类型都为menu,触发事件包含主题切换(changeTheme),触发事件(eventThing),销毁事件(resetEventThing),时间轴触发类型为time,触发事件(setTimeStamp),我们在操作树上挂载了主题的实例,事件及一些状态控制,事件被触发会找到对应的派发逻辑并在操作树上查询到对应节点进行对应方法的执行,结合单例设计模式,定制了init(),reset(),getInstance()等生命周期,可以约束开发者在其对应的类中进行逻辑的编写,事件的驱动将由统一的事件派发来进行。
// 恢复默认设置
const reset = () => {
// 回到默认视角
global.app.customCamera.cameraFly();
hideAllDevices();
}
module.exports = class MenuEvents {
constructor() {
this.executeConfig = [];
};
static getInstance() {
if (!MenuEvents._instance) {
MenuEvents._instance = new MenuEvents();
Object.freeze(MenuEvents)
}
return MenuEvents._instance;
}
changeTheme = async ({ name, data }) => {
const methodsConfig = require('../config/list');
this.executeConfig = [];
const length = name.length;
//如果有参数则全局存储该主题所需的参数
data && (global.themeParam = data);
//如果重复点击了主题,要去判断该主题的loop方法
if (length === 0 || JSON.stringify(global.themeMenu) === JSON.stringify(name)) {
this.findMatchingElements(methodsConfig, name, true);
this.executeConfig.forEach(item => {
//主题下不存在loop和方法就return
if (!item.loop || !item.methods) return;
//如果存在类,则执行类中的init,并全局存储类名
item.classCt && (item.classCt.init(item.param || {}), global.classCt = item.classCt);
// 没有就执行挂载的方法
(!item.classCt && item.methods) && item.methods.forEach(met => {
met.action(met.param || {})
})
})
return
};
//如果点击的主题跟上次的主题不一致,就执行销毁上次主题中的方法
(global.themeMenu.length !== 0 && JSON.stringify(global.themeMenu) !== JSON.stringify(name)) && await this.destroyTheme(global.themeMenu, name);
const oldMenu = global.themeMenu;
global.themeMenu = name;
reset();
global.themeList = this.findMatchingElements(methodsConfig, name, true);
let diff = false;
this.executeConfig.forEach((item, index) => {
if (oldMenu[index] !== item.name && !diff) {
diff = true;
}
if (!diff) return;
if (!item.methods) return;
//如果存在类,则执行类中的init,并全局存储类名
item.classCt && (item.classCt.init(item.param || {}), global.classCt = item.classCt) && this.filterfunc(item.methods, item.init, true);
// 没有就执行挂载的方法
(!item.classCt && item.methods) && item.methods.forEach(met => {
met.action(met.param || {})
})
})
};
//判断是否有历史主题,执行销毁和还原
destroyTheme = (oldlist, newList) => {
const methodsConfig = require('../config/list');
let diff = false;
this.findMatchingElements(methodsConfig, oldlist, false, true).forEach((item, index) => {
if (newList[index] !== item.name && !diff) {
diff = true;
}
if (!diff === true) return;
item.classCt && item.classCt.reset && item.classCt.reset();
(!item.classCt && item.methods) && item.methods.forEach(met => {
met.resetAction && met.resetAction(met.param || {})
})
this.filterfunc(item.methods, item.init);
})
};
filterfunc = (arr1, arr2, flag = false) => {
if (!arr2) {
arr1.forEach(i => {
i.isActive = false;
i.defaultParam && (i.param = i.defaultParam);
})
} else {
arr1.forEach(v => arr2.some(val => {
if (val === v.name && flag) {
v.isActive = flag;
if (!flag && v.defaultParam) {
v.param = v.defaultParam;
}
if (flag && !v.defaultParam) {
v.defaultParam = v.param;
}
}
if (val !== v.name && !flag) {
v.isActive = flag;
}
}))
}
}
//根据主题数组逐级递归
findMatchingElements = (data, queryArr, flag, delFlag) => {
if (!queryArr.length) {
return [];
}
const result = [];
const currentQuery = queryArr[0];
if (!data) return result;
const queryMap = {};//使用哈希表存储数据,提高查询效率
for (const item of data) {
queryMap[item.name] = item;
}
const currentItem = queryMap[currentQuery];
if (currentItem) {
flag && this.executeConfig.push(currentItem);
delFlag && result.push(currentItem);
if (queryArr.length === 1) {
result.push(currentItem);
} else {
const matchingChildren = this.findMatchingElements(currentItem.theme, queryArr.slice(1), flag);
if (matchingChildren.length) {
result.push(...matchingChildren);
}
}
}
return result;
};
eventfilter = (arr1, arr2) => {
arr1.forEach(v => arr2.some(val => {
val === v.name && (v.isActive = false);
}))
}
eventThing = (params) => {
// const methodsConfig = require('../config/list')
const length = global.themeMenu.length;
if (length === 0) return;
global.themeList.forEach(item => {
item.methods.forEach(met => {
if (met.action === params.methods) {
met.isActive = true;
met.exclusion && this.eventfilter(item.methods, met.exclusion);
!met.defaultParam && (met.defaultParam = met.param);
met.param = params.data;
item.classCt[met.action](params.data || {})
}
})
})
}
resetEventThing = (params) => {
// const methodsConfig = require('../config/list');
const length = global.themeMenu.length;
if (length === 0) return;
// this.findMatchingElements(methodsConfig, global.themeMenu, false)
global.themeList.forEach(item => {
item.methods.forEach(met => {
if (met.resetAction === params.methods && met.isActive) {
met.isActive = false;
met.defaultParam && (met.param = met.defaultParam);
item.classCt[met.resetAction](params.data || null);
}
})
})
}
event3D = (params) => {
if (!global.classCt) return;
global.classCt[params.methods] && global.classCt[params.methods](params.data || {});
}
};
这是最终事件派发的函数,开发者只需要关注自己主题的三维逻辑功能开发,事件调度和销毁将交给事件派发中心统一调度,方法和一个主题的业务逻辑被强解耦,增强了代码和业务的复用,也能轻松将业务进行拆解和二次开发,各主体间的开发人员将相互隔离,这在大型的三维项目开发中真的很受用,当然除了这些我们还做了更多的事情来进行辅助,在将来我会逐步分享。
到这里,其实时间轴的功能就很好实现了,我知道自己所处的主题以及该主题被激活的三维逻辑,2D会将时间戳不停的传过来,我只需要不断的调度类中的数据更新和对应的业务逻辑就好了
{
"type": "time",
"action": "setTimeStamp",
"params": {
"time": "3423534"
}
}
module.exports = class TimeEvents {
static getInstance() {
if (!TimeEvents._instance) {
TimeEvents._instance = new TimeEvents();
Object.freeze(TimeEvents)
}
return TimeEvents._instance;
}
setTimeStamp = ({ time }) => {
global.timeStamp = time;
const length = global.themeMenu.length;
if (length === 0 || !global.themeList) return;
global.themeList.forEach(item => {
item.methods.forEach(met => {
((typeof (met.action) == 'string' && met.action) && met.isActive && !met.noTime) && item.classCt[met.action](met.param || {});
((typeof (met.action) !== 'string' && met.action) && met.isActive && !met.noTime) && met.action(met.param)
})
})
};
}
当然,这或许不是最好的解决方案,但还是分享给大家,如果有更好的方案,欢迎交流。