Cesium深入浅出之插件封装
引子
一年多了,吭哧吭哧写了很多Cesium的代码,也做了不少Cesium插件,不过都是按照自定的格式封装的,突然想到Cesium也是有自己的插件格式的吧?我隐约记得在哪里看到过有个叫Mixin的东西,好像cesium-navigation插件就是用它来封装的。于是乎,翻了翻API,又了查看Cesium源码,发现Cesium中确实有类似的封装,基本可以确定这个模式没跑,那就开整吧。
预期效果
无图不欢,先上效果图,这是我封装的一个简易的地图选项插件。
实现原理
基本原理就是上面提到的Mixin,是混入的意思,也就是说要把插件混入到Cesium中,这个应该算是Cesium的插件规范吧,凡是按照这个规范封装的插件都可以使用viewer.extend()方法来实现对viewer的扩展,不仅用起来方便了,而且使你的代码结构更规范了。查看Cesium自带的CesiumInspector插件源码就会看到,viewerCesiumInspectorMixin中只有寥寥44行代码,而且有一半还是注释,里面就定义了一个mixin的方法体,viewer做为参数传入,然后调用了CesiumInspector类,这个类是插件的内部封装,而CesiumInspector又调用了CesiumInspectorViewModel,实现数据绑定。也就是说mixin最外层的一个封装规范,如同商品的包装盒,至于内部的具体实现,我们不得不提一下knockout了,就是利用它实现的html元素与ViewModel的关联,进而实现数据绑定的。
具体实现
创建插件文件
在src下创建插件目录及相关文件,通常我们会将插件放到src/widgets目录下,并为每个插件创建独立的目录,这样做能充分体现插件的独立性特征,而且便于管理。本例的目录结构如下:
▼📂src
▼📂widgets
▼📂MapOptions
MapOptions.css
MapOptions.html
MapOptions.js
MapOptionsViewModel.js
viewerMapOptionsMixin.js
当然你完全可以按照自己的习惯来组织代码结构,如果你采用vue开发,甚至可以忽略本篇了,不过这些都属于本篇范畴,不过多讨论了。下面让我们由外而内逐层抽丝剥茧。
外部封装
这里指的就是Mixin方式封装,与应用层直接打交道的,调用代码如下:
1 const viewer = new Viewer('cesiumContainer'); 2 viewer.extend(viewerMapOptionsMixin);
嗯,调用方式很简单,就是viewer调用扩展方法传入插件参数,如果还有其他参数的话,可以在extend方法中再添加一个options参数进行扩展即可。它的实现也很简单,主要是代码规范,没有多少实质性内容,如下:
1 import defined from "cesium/Source/Core/defined.js"; 2 import DeveloperError from "cesium/Source/Core/DeveloperError.js"; 3 import MapOptions from "./MapOptions.js"; 4 import "./MapOptions.css" 5 6 /** 7 * A mixin which adds the MapOptions widget to the Viewer widget. 8 * Rather than being called directly, this function is normally passed as 9 * a parameter to {@link Viewer#extend}, as shown in the example below. 10 * 11 * @function 12 * @param {Viewer} viewer The viewer instance. 13 * @param {Object} [options={}] The options. 14 * @exception {DeveloperError} viewer is required. 15 * @demo {@link http://helsing.wang:8888/simple-cesium | MapOptions Demo} 16 * @example 17 * var viewer = new Cesium.Viewer('cesiumContainer'); 18 * viewer.extend(viewerMapOptionsMixin); 19 */ 20 function viewerMapOptionsMixin(viewer, options = {}) { 21 if (!defined(viewer)) { 22 throw new DeveloperError("viewer is required."); 23 } 24 25 const container = document.createElement("div"); 26 container.className = "sc-widget-container"; 27 viewer.container.appendChild(container); 28 const widget = new MapOptions( 29 viewer, {container: container} 30 ); 31 32 // Remove the mapOptions property from viewer. 33 widget.addOnDestroyListener((function (viewer) { 34 return function () { 35 defined(container) && viewer.container.removeChild(container); 36 delete viewer.mapOptions; 37 } 38 })(viewer)) 39 40 // Add the mapOptions property to viewer. 41 Object.defineProperties(viewer, { 42 mapOptions: { 43 get: function () { 44 return widget; 45 }, 46 configurable: true 47 }, 48 }); 49 } 50 51 export default viewerMapOptionsMixin;
上述的结构主要是参考了Cesium源码中的viewerCesiumInspectorMixin文件,甚至连注释都是,哈哈,要学规范就彻底点。下面简单讲解一下代码:
首先,动态创建一个container,就是插件所依托的容器,将这个容器放到Cesium的容器中,当然你也可以放到你想要的任何地方。
然后,初始化MapOptions,这个对象是插件的具体实现,我们下面会讲。
最后,为viewer添加一个mapOptions属性,这样你就可以直接从viewer中点出你的插件了。这里我在原有基础上额外加了一段删除属性的代码,就是在插件销毁的时候把mapOptions属性从viewer中移除,这个是参考cesium-navigation做的。要注意的是,如果加了这段代码,一定要将mapOptions属性定义设置为可配置,就是configurable: true,否则在删除属性的时候回报错,因为默认的configurable值为false。
内部封装
所谓内部封装其实也就是在Mixin封装的容器下面装载自己的HTML元素,然后挂接ViewModel。我没有完全参照Cesium的源码,而是采用纯ES6的方式封装的Class:
1 class MapOptions { 2 3 /** 4 * Gets the parent container. 5 * @memberOf MapOptions.prototype 6 * @type {Element} 7 */ 8 get container() { 9 return this._container; 10 } 11 /** 12 * Gets the view model. 13 * @memberOf MapOptions.prototype 14 * @type {MapOptionsViewModel} 15 */ 16 get viewModel() { 17 return this._viewModel; 18 } 19 20 constructor(viewer, options={}) { 21 this._element = undefined; 22 this._container= undefined; 23 this._viewModel= undefined; 24 this._onDestroyListeners= []; 25 26 if (!defined(viewer)) { 27 throw new DeveloperError("viewer is required."); 28 } 29 if (!defined(options)) { 30 throw new DeveloperError("container is required."); 31 } 32 const scene = viewer.scene; 33 let container = options.container; 34 typeof options === "string" && (container = options); 35 container = getElement(container); 36 const element = document.createElement("div"); 37 element.className = "sc-widget"; 38 insertHtml(element, MapOptionsHtml); 39 container.appendChild(element); 40 const viewModel = new MapOptionsViewModel(viewer, element); 41 42 this._viewModel = viewModel; 43 this._element = element; 44 this._container = container; 45 46 // 绑定viewModel和element 47 knockout.applyBindings(viewModel, element); 48 } 49 50 /** 51 * @returns {Boolean} true if the object has been destroyed, false otherwise. 52 */ 53 isDestroyed () { 54 return false; 55 } 56 57 /** 58 * Destroys the widget. Should be called if permanently. 59 * removing the widget from layout. 60 */ 61 destroy () { 62 if (defined(this._element)) { 63 knockout.cleanNode(this._element); 64 defined(this._container) && this._container.removeChild(this._element); 65 } 66 delete this._element; 67 delete this._container; 68 69 defined(this._viewModel) && this._viewModel.destroy(); 70 delete this._viewModel; 71 72 for (let i = 0; i < this._onDestroyListeners.length; i++) { 73 this._onDestroyListeners[i](); 74 } 75 76 return destroyObject(this); 77 } 78 79 addOnDestroyListener(callback) { 80 if (typeof callback === 'function') { 81 this._onDestroyListeners.push(callback) 82 } 83 } 84 }
友情提醒一下,本文中有可能会涉及一些自定义的方法,如果文章里找不到的话,请参考文章最后的github地址中的内容。
上述代码除了动态添加一个插件元素之外,基本还是一个代码规范的封装,如destroy就是销毁插件的方法。另外就是看一下它如何跟ViewModel联动的,看代码knockout.applyBindings(viewModel, element),就是说这里是使用的是knockout进行联动的,不过暂且先不讲那么多,在ViewModel中我们会再详细研究。其他的没什么好说的,接着往下看。
ViewModel
VIewModel到底是个啥?不搞清楚概念就很难理解插件的精髓。其实就是字面理解,视图+模型,我们在文章开头就说了,使用knockout来时实现HTML元素与数据对象的绑定,而这个VIewModel就是数据绑定的具体实现。让我们先来看一个简单ViewModel的实现:
1 var viewModel = { 2 shadows: true 3 }; 4 knockout.track(viewModel); // 1st 5 knockout.applyBindings(viewModel, element); // 2nd 6 knockout 7 .getObservable(this, "frustums") 8 .subscribe(function (val) { 9 viewer.shadows = val; 10 viewer.scene.requestRender(); 11 }); // 3rd
上述实现的是地图的阴影效果选项,只要三个步骤就可以实现数据绑定了,是不是很简单?
首先是要将你要绑定的属性放入ViewModel中,在本篇中我们是将ViewModel封装成class了,效果一样,但在使用中有些小小差别,接下来会讲到的。
然后三步走,第一步,track,就是追踪的意思吧,用来追踪ViewModel,一有风吹草动就向“上级”汇报;第二步,applyBindings,应用绑定,与“上级”建立接头联络信号;第三步,先是getObservable,暗中观察,再是subscribe,订阅,即发现目标后具体要怎么做,比如逮捕或者直接办了,哈哈。
接下来看看本文中的相关代码实现吧,相信你已经很容易就看懂了:
1 class MapOptionsViewModel { 2 constructor(viewer, container) { 3 if (!defined(viewer)) { 4 throw new DeveloperError("viewer is required"); 5 } 6 if (!defined(container)) { 7 throw new DeveloperError("container is required"); 8 } 9 10 const that = this; 11 const scene = viewer.scene; 12 const globe = scene.globe; 13 const canvas = scene.canvas; 14 const eventHandler = new ScreenSpaceEventHandler(canvas); 15 16 this._scene = viewer.scene; 17 this._eventHandler = eventHandler; 18 this._removePostRenderEvent = scene.postRender.addEventListener(function () { 19 that._update(); 20 }); 21 this._subscribes = []; 22 23 Object.assign(this,{"viewerShadows":false, 24 "globeEnableLighting":false, 25 "globeShowGroundAtmosphere":true, 26 "globeTranslucencyEnabled":false, 27 "globeShow":false, 28 "globeDepthTestAgainstTerrain":false, 29 "globeWireFrame":false, 30 "sceneSkyAtmosphereShow":true, 31 "sceneFogEnabled":true, 32 "sceneRequestRenderMode":false, 33 "sceneLogarithmicDepthBuffer":false, 34 "sceneDebugShowFramesPerSecond":false, 35 "sceneDebugShowFrustumPlanes":false, 36 "sceneEnableCollisionDetection":false, 37 "sceneBloomEnabled":false}) 38 knockout.track(this); 39 /*knockout.track(this, [ 40 "viewerShadows", 41 "globeEnableLighting", 42 "globeShowGroundAtmosphere", 43 "globeTranslucencyEnabled", 44 "globeShow", 45 "globeDepthTestAgainstTerrain", 46 "globeWireFrame", 47 "sceneSkyAtmosphereShow", 48 "sceneFogEnabled", 49 "sceneRequestRenderMode", 50 "sceneLogarithmicDepthBuffer", 51 "sceneDebugShowFramesPerSecond", 52 "sceneDebugShowFrustumPlanes", 53 "sceneEnableCollisionDetection", 54 "sceneBloomEnabled" 55 ]);*/ 56 const props = [ 57 ["viewerShadows", viewer, "shadows"], 58 ["globeEnableLighting", globe, "enableLighting"], 59 ["globeShowGroundAtmosphere", globe, "showGroundAtmosphere"], 60 ["globeTranslucencyEnabled", globe.translucency, "enabled"], 61 ["globeShow", globe, "show"], 62 ["globeDepthTestAgainstTerrain", globe, "depthTestAgainstTerrain "], 63 ["globeWireFrame", globe._surface.tileProvider._debug, "wireframe "], 64 ["sceneSkyAtmosphereShow", scene.skyAtmosphere, "show"], 65 ["sceneFogEnabled", scene.fog, "enabled"], 66 ["sceneRequestRenderMode", scene, "requestRenderMode"], 67 ["sceneLogarithmicDepthBuffer", scene, "logarithmicDepthBuffer"], 68 ["sceneDebugShowFramesPerSecond", scene, "debugShowFramesPerSecond"], 69 ["sceneDebugShowFrustumPlanes", scene, "debugShowFrustumPlanes"], 70 ["sceneEnableCollisionDetection", scene.screenSpaceCameraController, "enableCollisionDetection"], 71 ["sceneBloomEnabled", scene.postProcessStages.bloom, "enabled"] 72 ]; 73 props.forEach(value => this.subscribe(value[0], value[1], value[2])); 74 } 75 76 _update() { 77 // 先预留着吧 78 } 79 80 destroy() { 81 this._eventHandler.destroy(); 82 this._removePostRenderEvent(); 83 for (let i = this._subscribes.length - 1; i >= 0; i--) { 84 this._subscribes[i].dispose(); 85 this._subscribes.pop(); 86 } 87 return destroyObject(this); 88 } 89 90 subscribe(name, obj, prop) { 91 const that = this; 92 const result = knockout 93 .getObservable(that, name) 94 .subscribe(() => { 95 obj[prop] = that[name]; 96 that._scene.requestRender(); 97 if (name === "sceneEnableCollisionDetection"){ 98 obj[prop] = !that[name]; 99 } 100 }); 101 // .subscribe(value => { 102 // obj[prop] = that[name];//value; 103 // that._scene.requestRender(); 104 // if (name === "sceneEnableCollisionDetection"){ 105 // obj[prop] = !value; 106 // } 107 // }); 108 this._subscribes.push(result); 109 console.log(this.globeShowGroundAtmosphere); 110 } 111 }
经过我上面的“提纲挈领”之后,代码很容就看懂了吧。就只说一点,看下我注释掉的代码,之所以还保留着是因为那是Cesium自带插件中的写法,这种写法会导致无法设置默认值,表现在界面上的就是复选框全部未选中。当然了,不是说这种写法是错误的,Cesium是有别代码来处理这件事情的,但我们还是怎么简单怎么来吧。先将属性都绑定到this对象也就是ViewModel上,然后再赋上初值就很哦了。
HTML
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>MapOptions</title> 6 </head> 7 <body> 8 <div class="sc-widget-title">地图选项</div> 9 <div class="sc-widget-content"> 10 <div><span>视窗选项</span></div> 11 <label><input type="checkbox" data-bind="checked: viewerShadows"><span>阴影效果</span></label> 12 <div><span>地球选项</span></div> 13 <label><input type="checkbox" data-bind="checked: globeEnableLighting"><span>阳光效果</span></label> 14 <label><input type="checkbox" data-bind="checked: globeShowGroundAtmosphere"><span>地表大气</span></label> 15 <label><input type="checkbox" data-bind="checked: globeTranslucencyEnabled"><span>地表透明</span></label> 16 <label><input type="checkbox" data-bind="checked: globeShow"><span>显示地球</span></label> 17 <label><input type="checkbox" data-bind="checked: globeDepthTestAgainstTerrain"><span>深度检测</span></label> 18 <label><input type="checkbox" data-bind="checked: globeWireFrame"><span>地形线框</span></label> 19 <div><span>场景选项</span></div> 20 <label><input type="checkbox" data-bind="checked: sceneSkyAtmosphereShow"><span>天空大气</span></label> 21 <label><input type="checkbox" data-bind="checked: sceneFogEnabled"><span>显示雾气</span></label> 22 <label><input type="checkbox" data-bind="checked: sceneRequestRenderMode"><span>主动渲染</span></label> 23 <label><input type="checkbox" data-bind="checked: sceneLogarithmicDepthBuffer"><span>对数深度</span></label> 24 <label><input type="checkbox" data-bind="checked: sceneDebugShowFramesPerSecond"><span>码率帧数</span></label> 25 <label><input type="checkbox" data-bind="checked: sceneDebugShowFrustumPlanes"><span>显示视锥</span></label> 26 <label><input type="checkbox" data-bind="checked: sceneEnableCollisionDetection"><span>地下模式</span></label> 27 <label><input type="checkbox" data-bind="checked: sceneBloomEnabled"><span>泛光效果</span></label> 28 </div> 29 </body> 30 </html>
注意绑定的属性都是写再data-bind中的哦,本例中只用到cheked一个属性,下一篇的图层管理中会有更丰富的应用,敬请期待。
CSS
1 .sc-widget-container { 2 position: absolute; 3 top: 50px; 4 left: 10px; 5 width: 200px; 6 /*height: 400px;*/ 7 padding: 2px; 8 background: rgba(0, 0, 0, .5); 9 border-radius: 5px; 10 border: 1px solid #444; 11 } 12 .sc-widget { 13 width: 100%; 14 height: 100%; 15 transition: width ease-in-out 0.25s; 16 display: inline-block; 17 position: relative; 18 -moz-user-select: none; 19 -webkit-user-select: none; 20 -ms-user-select: none; 21 user-select: none; 22 overflow: hidden; 23 } 24 .sc-widget .sc-widget-title { 25 height: 20px; 26 border-bottom: 2px solid #eeeeee; 27 } 28 .sc-widget .sc-widget-content { 29 padding: 5px; 30 width: calc(100% - 10px); 31 height: calc(100% - 20px); 32 } 33 .sc-widget-content label { 34 display: flex; 35 align-items: center; 36 height: 25px; 37 } 38 .sc-widget-content div span:after { 39 content: ''; 40 width: 60%; 41 position: absolute; 42 border-top: 1px solid #c8c8c8; 43 margin: 8px 4px; 44 }
小结
好啦,上面就是插件封装的基本原来,以及完整代码实现。本文整体画风依旧保持简单易懂,旨在为大家提供一个插件封装的方式或者规范罢了,然后就是讲了一点knockout和ViewModel的小知识,算是为下一篇图层管理插件做铺垫。
相关资源
GitHub地址:https://github.com/HelsingWang/simple-cesium
Demo地址:http://helsing.wang:8888/simple-cesium
Cesium深入浅出系列CSDN地址:https://blog.csdn.net/fywindmoon
Cesium深入浅出系列博客园地址:https://www.cnblogs.com/HelsingWang
交流群:854943530
以上资源随时优化升级中,如有与文章中不一样的地方纯属正常,以最新的为主。
posted on 2021-01-11 13:34 Helsing·Wang 阅读(3491) 评论(0) 编辑 收藏 举报