引子
一年多了,吭哧吭哧寫了很多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
以上資源隨時優化升級中,如有與文章中不一樣的地方純屬正常,以最新的為主。