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编辑  收藏  举报

导航