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

以上資源隨時優化升級中,如有與文章中不一樣的地方純屬正常,以最新的為主。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM