Cesium官方教程3 -- 空間數據可視化

原文地址:https://cesiumjs.org/tutorials/Visualizing-Spatial-Data/
這篇教程教你如何使用Cesium的Entity API去繪制空間數據,如點,圖標,文字標注,折線,模型,圖形和立體圖形。雖然這章不需要什么前提,但是如果你對Cesium一無所知,最好從第一個教程開始

Entity API是什么?

Cesium豐富的空間數據可視化API分為兩部分:Primitive API 面向三維圖形開發者,更底層一些。Entity API 是數據驅動更高級一些。

Primitive API的主要目的是為了完成(可視化)任務的最少的抽象需求。他要求我們以一個圖形開發者的方式去思考,并且使用了一些圖形學術語。它是為了最高效最靈活的實現可視化效果,忽略了API的一致性。比如繪制三維模型和創建Billboard不同,和多邊形繪制更是徹底不同。每種可視化都有自己鮮明的特色。此外,他們每種都有自己的獨特的性能提升方式,也需要遵守不同的優化原則。雖然它很強大又很靈活,但是大多數項目需要比Primitive API更高層次的抽象。

Entity AP的主要目的是定義一組高級對象,它們把可視化和信息存儲到統一的數據結果中,這個對象叫Entity。 它讓我們更加關注我們的數據展示而不是底層的可視化機制。它提供了很方便的創建復雜的,與靜態數據相匹配的隨時間變化的可視化效果。Entity API實際內部在使用Primitive API ,它的實現細節,我們無需關心。經過各種數據的測試,Entity API提供靈活的,高層次的可視化,同時暴露一些一致性的、容易去學習和使用的接口。

第一個 Entity

學習Entity API基本使用的最好方式就是去讀代碼。簡單其間,我們使用Sandcastle去創建 Hello World 示例。如果你已經創建了本地的cesium項目,那么使用你自己的項目。
假設,我們需要從經緯度列表中創建美國懷俄明州(選擇懷俄明州Wyoming,是因為它的邊界足夠簡單)的多邊形。把下面的代碼粘貼拷貝到Sandcastle中去:

var viewer = new Cesium.Viewer('cesiumContainer');

var wyoming = viewer.entities.add({
  name : 'Wyoming',
  polygon : {
    hierarchy : Cesium.Cartesian3.fromDegreesArray([
                              -109.080842,45.002073,
                              -105.91517,45.002073,
                              -104.058488,44.996596,
                              -104.053011,43.002989,
                              -104.053011,41.003906,
                              -105.728954,40.998429,
                              -107.919731,41.003906,
                              -109.04798,40.998429,
                              -111.047063,40.998429,
                              -111.047063,42.000709,
                              -111.047063,44.476286,
                              -111.05254,45.002073]),
    height : 0,
    material : Cesium.Color.RED.withAlpha(0.5),
    outline : true,
    outlineColor : Cesium.Color.BLACK
  }
});

viewer.zoomTo(wyoming);

單機Run 按鈕(或者按下F8)就看到如下圖所示效果:


懷俄明州

第一個 entity.懷俄明州從來沒有讓人如此興奮.
我們盡力使Cesium的代碼容易理解,上面的代碼不用解釋也應該明白什么意思。首先創建Cesium程序的基礎對象 Viewer widget, 然后使用viewer.entities.add添加 Entity。傳給 add 方法的參數一個包含了初始化配置的js 對象. 返回值就是 entity 對象. 最后調用 viewer.zoomTo 定位到到這個entity。

Entity 的配置項里有大量的參數,但是現在我們只是設置了 polygon 的填充面為半透明紅色,邊界線時黑色的。最后把這個entity命名為“Wyoming”。

面和體

學了基礎的添加多邊形知識,多虧Entity API的一致性非常好,我們結合Sandcastle 的示例,就很容易就創建各種圖形。下面是所有支持的面和體的圖形列表:

Box

六面體盒子entity.box

Ellipse

圓和橢圓entity.ellipse

Corridor

Corridor entity.corridor

Cylinder

圓柱和圓錐 entity.cylinder

Polygon

多邊形 entity.polygon

Polyline

折線 entity.polyline

Volume

Polyline Volumes entity.polylineVolume

Rectangle

矩形 entity.rectangle

Ellipsoid

球和橢球 entity.ellipsoid

Wall

entity.wall

材質和邊線

無論他們的幾何體有什么不同,所有形狀和體都有一系列相同的屬性來控制它們的外觀。fill 為boolean類型,控制表面是否填充。 outline 屬性控制是否有外邊界。
fill=truematerial屬性決定了用什么材質填充表面。下個例子,我們創建一個半透明橢圓。默認fill=trueoutline=false,所以我們只需要設置material屬性。

var entity = viewer.entities.add({
  position: Cesium.Cartesian3.fromDegrees(-103.0, 40.0),
  ellipse : {
    semiMinorAxis : 250000.0,
    semiMajorAxis : 400000.0,
    material : Cesium.Color.BLUE.withAlpha(0.5)
  }
});
viewer.zoomTo(viewer.entities);
var ellipse = entity.ellipse;  
半透明橢圓

圖片材質

直接設置一個圖片的url就可以了。

ellipse.material = '//cesiumjs.org/tutorials/images/cats.jpg';

圖片材質

上面兩個示例李, 當設置顏色或者url之后Cesium會自動創建 ColorMaterialProperty 或者ImageMaterialProperty對象。 對于更復雜的材質, 需要手動創建 MaterialProperty對象。 當前, Entity 面和體支持 顏色(colors),紋理圖片( images),棋盤 (checkerboard), 條紋(stripe), 網格(grid)等材質.

網格材質

ellipse.material = new Cesium.CheckerboardMaterialProperty({
  evenColor : Cesium.Color.WHITE,
  oddColor : Cesium.Color.BLACK,
  repeat : new Cesium.Cartesian2(4, 4)
});
網格材質

條紋材質

ellipse.material = new Cesium.StripeMaterialProperty({
  evenColor : Cesium.Color.WHITE,
  oddColor : Cesium.Color.BLACK,
  repeat : 32
});

條紋材質

網格材質

ellipse.material = new Cesium.GridMaterialProperty({
  color : Cesium.Color.YELLOW,
  cellAlpha : 0.2,
  lineCount : new Cesium.Cartesian2(8, 8),
  lineThickness : new Cesium.Cartesian2(2.0, 2.0)
});

網格材質

邊線

fill屬性不太一樣,outline沒有對應的材質配置,而是用兩個獨立的屬性outlineColoroutlineWidth
注意outlineWidth屬性僅僅在非windows系統上有效,比如Android, iOS, Linux, 和OS X。Windows系統上邊線寬度永遠為1。主要是因為三大主流瀏覽器引擎在windows平臺上實現webgl上的技術限制。

ellipse.fill = false;
ellipse.outline = true;
ellipse.outlineColor = Cesium.Color.YELLOW;
ellipse.outlineWidth = 2.0;

邊線

折線

折線是個特例,他沒有填充或者邊線屬性。除了顏色它有專門的材質屬性。由于這種特殊材質,折線寬度和折線的邊線寬度,在所有系統都有效。

var entity = viewer.entities.add({
    polyline : {
        positions : Cesium.Cartesian3.fromDegreesArray([-77, 35,
                                                        -77.1, 35]),
    width : 5,
    material : Cesium.Color.RED
}});
viewer.zoomTo(viewer.entities);
var polyline = entity.polyline // For upcoming examples
折線

折線邊線

polyline.material = new Cesium.PolylineOutlineMaterialProperty({
    color : Cesium.Color.ORANGE,
    outlineWidth : 3,
    outlineColor : Cesium.Color.BLACK
});

折線的邊線

折線輝光

polyline.material = new Cesium.PolylineGlowMaterialProperty({
    glowPower : 0.2,
    color : Cesium.Color.BLUE
});

折線輝光

高度和垂直擠壓(Extrusions)

所有的面形狀都是平鋪在地球上,當前 圓(circles)、橢圓(ellipses)、多邊形(polygons)、矩形(rectangles)可以有一個高程屬性 或者 垂直擠壓變成體。這兩種情況種,這些面或者體仍然會貼合地球曲率。
上面我們列出的所有圖形,都是只需要在圖形對象(graphics )上設置一個高度屬性即可。這里順便說明下,除非在函數上明確說明,否則Cesium總是使用米、弧度、秒做為標準單位。如 Cartesian3.fromDegrees.
下面這行代碼把多邊形放到了 250,000米高空。

wyoming.polygon.height = 250000;
250,000 米高空的懷俄明

把圖形擠壓為體,也非常簡單。僅僅需要設置 extrudedHeight 屬性。將會創建一個在heightextrudedHeight之間的體塊。如果 height 沒有定義, 體塊從 0高程開始。下面代碼創建一個從200,000米到 250,000米的體 。也就是說這個體的高度是50000米。

wyoming.polygon.height = 200000;
wyoming.polygon.extrudedHeight = 250000;
垂直擠壓

對多邊形變成體也非常容易

Viewer中的Entity 元素(feature)

在開始其他可視化效果學習之前,讓我們先看看 Viewer 中提供的和Entity相關的函數。

選中和描述

除非明確禁用,否則點擊Entity將在它的位置會顯示 SelectionIndicator 控件,并且在 InfoBox 控件里顯示它的描述信息。回想我們最開始的示例,我們僅僅為 wyoming entity設置了name屬性,它顯示在 InfoBox標題欄, 也可以通過 Entity.description 設置一段HTML當作infobox的內容。 把下面的代碼追加到上面的示例里:

wyoming.description = '\
<img\
  width="50%"\
  style="float:left; margin: 0 1em 1em 0;"\
  src="http://cesiumjs.org/tutorials/Visualizing-Spatial-Data/images/Flag_of_Wyoming.svg"/>\
<p>\
  Wyoming is a state in the mountain region of the Western \
  United States.\
</p>\
<p>\
  Wyoming is the 10th most extensive, but the least populous \
  and the second least densely populated of the 50 United \
  States. The western two thirds of the state is covered mostly \
  with the mountain ranges and rangelands in the foothills of \
  the eastern Rocky Mountains, while the eastern third of the \
  state is high elevation prairie known as the High Plains. \
  Cheyenne is the capital and the most populous city in Wyoming, \
  with a population estimate of 62,448 in 2013.\
</p>\
<p>\
  Source: \
  <a style="color: WHITE"\
    target="_blank"\
    >Wikpedia</a>\
</p>';

設置Entity描述信息

很多項目都是從服務端返回描述信息,而不是上面這種硬編碼,不過這種方法是可行的。
默認,在InfoBox 里所有的HTML是沙盒模式。這個防止外部的數據注入惡意的代碼。如果你需要在描述信息里運行js腳本或者瀏覽器插件,可以通過viewer.infoBox.frame屬性來訪問這個iframe。更多關于iframe的沙盒模式,請參考這篇文章

相機控制

就像第一個例子中,我們使用 zoomTo 命令去顯示一個特定的entity。雙擊Entity或者點擊 InfoBox左上角按鈕,也能達到同樣效果. 還有一個 flyTo 方法,它不是立即定位過去,而是執行一個相機動畫漸變過去。這些方法除了應用在單獨一個entity上,也可以作用在 EntityCollection對象上或者一個普通的js entity數組,。
默認,這些方法會自動計算一個視圖,確保所有所有傳到方法里的entity都可見,相機朝向正北,以45°傾斜俯視。可以提供一個自定義的heading, pitch, and range.來修改這個朝向。下面代碼執行后相機會從東方向下傾斜30°角去看懷俄明的多邊形。因為我們沒有設定range參數,那么這個參數還是按照默認計算的結果。

var heading = Cesium.Math.toRadians(90);
var pitch = Cesium.Math.toRadians(-30);
viewer.zoomTo(wyoming, new Cesium.HeadingPitchRange(heading, pitch));

自定義視角

zoomToflyTo 都是異步函數, 也就是說當函數return的時候,并不能保證執行完畢了。一般flyto會在很多個動畫幀里都運算。這些函數都返回一個 Promises ,我們可以把飛行或者縮放完成后需要制定的代碼放到 then函數里。我們把以下代碼片段里換成 zoomTo ,并且在飛行完畢后會同時選中這個entity。

viewer.flyTo(wyoming).then(function(result){
    if (result) {
        viewer.selectedEntity = wyoming;
    }
});

這里回調函數里的result參數,true表示飛行正常完成,false 飛行被打斷 或者 用戶開啟了另一個飛行定位函數,再或者目標對象無法被可視化也就沒辦法去定位了。
有時候,尤其是展示一個隨時間變化的數據,我們希望相機能跟隨這個entity。這個通過設置 viewer.trackedEntity就很容易實現。跟隨一個entity要求position屬性必須存在。還是通過我們的Wyoming 多邊形entity來測試這個模式,我們給它增加個position屬性,代碼如下:

wyoming.position = Cesium.Cartesian3.fromDegrees(-107.724, 42.68);
viewer.trackedEntity = wyoming;

viewer.trackedEntity 設置為undefined 或者點擊 InfoBox的左上的取消按鈕都可以停止跟隨模式。 調用zoomTo 或者 flyTo 也會取消跟隨模式,并且 把 viewer.trackedEntity 設置為 undefined
大部分情況下,在 Viewer 中定義的和entity相關的相機函數足夠使用了。但是如果你想在項目更多的自定義相機視圖方式,請查看 相機教程

管理Entity集合

EntityCollection類是一個Entity數組集合,用來它管理和控制一組entity非常方便。我們已經見過它的一個實例 viewer.entities 屬性。EntityCollection 提供了基本的數組方法 add, remove, 和 removeAll;同時還有下面我們要討論的一些特有方法或者屬性。
很多項目的數據實際都是存在服務端的,只有客戶端需要的時候才會加載。有時候需要更改一個我們已經創建的entity。所有entity對象都有一個獨一無二的 id 屬性,這種情況情況下就非常有用。前面的示例里,我們并沒有指定這個id,Cesium會自動生成一個 GUID 類似182bdba4-2b3e-47ae-bf0b-83f6fde285fd 填充到id屬性里。服務端的數據一般都有自己主鍵id屬性,所以可以在enity創建的時候指定這個id。

viewer.entities.add({
    id : 'uniqueId'
});

隨后,可以通過 getById來獲取Entity對象。如果沒有找到對應的id,那么該方法返回 undefined

var entity = viewer.entities.getById('uniqueId');

另一個常見的應用,是如果id不存在就新建,如果id存在就更新。 getOrCreateEntity 總會返回以傳入的參數為id的對象實例, 如果id不存在,那么會新建一個,并且增加到entity集合里,然后返回。

var entity = viewer.entities.getOrCreateEntity('uniqueId');

最后,簡單的通過 add就可以新建一個Entity實例。這種情況下,add函數會檢測如果傳入了一個已經存在的id,那么會報異常。

var entity = new Entity({
    id : 'uniqueId'
});
viewer.entities.add(entity);

EntityCollection 最強大的功能其實是collectionChanged Event,我們用它來接收集合里entity被添加、刪除甚至更新的通知。當項目里的用戶界面或者某個功能需要監控集合里的對象改變的時候,這個功能非常有用。

為了驗證這點,可以試下Sandcastle的實例 Geometry 示例 。把下面的代碼拷貝到緊跟viewer 創建的地方。

function onChanged(collection, added, removed, changed){
  var msg = 'Added ids';
  for(var i = 0; i < added.length; i++) {
    msg += '\n' + added[i].id;
  }
  console.log(msg);
}
viewer.entities.collectionChanged.addEventListener(onChanged);

當運行示例的時候,控制臺輸出了65條消息。每調用一次 viewer.entities.add就會有一條消息 ( removedchanged在這里沒有提示,因為我們這個項目里只有add)。為了更新可視化效果,Cesium內部實際也訂閱了這個事件。當一次性更新的數量過多的時候,先一個個更新,最后統一發消息效率更高。因為Cesium只處理了一遍變化消息,所以這個對性能有提升。 在修改之前,我們先調用 viewer.entities.suspendEvents,修改完之后再調用 viewer.entities.resumeEvents.

我們試下這個。在第一次調用 viewer.entities.add 前添加一個suspend調用,在最后調用一下resume 。再次運行下程序,我們現在只收到一條消息,但是里面包含了65條entity添加記錄。 這個函數調用有內部計數,所以多重嵌入調用suspend 和resume沒有任何問題。可是,如果忘了調用resume,那么在處理完之后會獲取不了任何信息。因為resume只有在對應層次的suspend下才會發出消息(也就是suspend和resume必須是匹配的)。

拾取

拾取,也就是返回特定屏幕坐標(通常是鼠標位置)的對象,這也是這部分唯一需要和Primitive API打交道的功能。這部分未來在講Cesium的Entity拾取技術功能的時候會再次討論。 現在我們使用一些低層次的方法 scene.pickscene.drillPick 。下面代碼是拾取部分的一個基本實現,基本上可以直接在項目里使用 。

/**
 * 返回對應窗口位置最上面一個Entity 如果該位置沒有對象那么返回undefined
 * @param {Cartesian2} windowPosition 窗口坐標
 * @returns {Entity} 返回值
 */
function pickEntity(viewer, windowPosition) {
  var picked = viewer.scene.pick(windowPosition);
  if (defined(picked)) {
    var id = Cesium.defaultValue(picked.id, picked.primitive.id);
    if (id instanceof Cesium.Entity) {
      return id;
    }
  }
  return undefined;
};

/**
 *返回對應窗口位置所有Entity的列表 如果該位置沒有對象那么返回undefined
 * 返回值按可視化順序從前到后存儲在數組里
 *
 * @param {Cartesian2} windowPosition 窗口位置
 * @returns {Entity[]}  
 */
function drillPickEntities(viewer, windowPosition) {
  var i;
  var entity;
  var picked;
  var pickedPrimitives = viewer.scene.drillPick(windowPosition);
  var length = pickedPrimitives.length;
  var result = [];
  var hash = {};

  for (i = 0; i < length; i++) {
    picked = pickedPrimitives[i];
    entity = Cesium.defaultValue(picked.id, picked.primitive.id);
    if (entity instanceof Cesium.Entity &&
        !Cesium.defined(hash[entity.id])) {
      result.push(entity);
      hash[entity.id] = true;
    }
  }
  return result;
};

來解釋下。 場景的拾取函數返回的是圖元信息而不是entity對象,但是Entity API的結構限定每一個圖元會對應到一個entity實體上,通過他們的 id 屬性來區分。所以我們只需要檢測拾取的對象id是否是一個 Entity. 這些函數是不重要的(trivial),它還沒有被當作Cesium的正式部分,我們有一些更加穩定的函數計劃(more robust functionality planned) 。

點(Points),公告牌( Billboards), 標注(Labels)

別考慮面和體了,我們來學下在Cesium上如何展示POI點。 創建一個點或者標注非常簡單,只需要設置entity 的 position 屬性,以及point 或者label 可視化對象。比如,我想在我最喜歡的球隊主場放一個點。

var viewer = new Cesium.Viewer('cesiumContainer');

var citizensBankPark = viewer.entities.add({
    name : 'Citizens Bank Park',
    position : Cesium.Cartesian3.fromDegrees(-75.166493, 39.9060534),
    point : {
        pixelSize : 5,
        color : Cesium.Color.RED,
        outlineColor : Cesium.Color.WHITE,
        outlineWidth : 2
    },
    label : {
        text : 'Citizens Bank Park',
        font : '14pt monospace',
        style: Cesium.LabelStyle.FILL_AND_OUTLINE,
        outlineWidth : 2,
        verticalOrigin : Cesium.VerticalOrigin.BOTTOM,
        pixelOffset : new Cesium.Cartesian2(0, -9)
    }
});

viewer.zoomTo(viewer.entities);

點和標注

上面的示例里,我們精確指定了公告牌的寬度和高度,但其實是不需要的,如果沒有指定,那么將使用圖片的高度和寬度。
標注和公告板有大量的選項,我們就不深入講解了。具體可以查看Sandcastle里的對應示例: 標注, 公告板

三維模型

Cesium通過 glTF格式支持三維模型,glTF是 WebGL, OpenGL ES, and OpenGL的實時載入模型(the runtime asset format)。Cesium包含了一些可以使用的glTF模型 : 帶螺旋槳動畫的飛機,帶輪子動畫的汽車模型,帶行走動畫的人物模型。在Sandcastle 示例里可以看到他們 三維模型
加載三維模型和前面其他的可視數據區別不大。只需要entity帶position屬性和一個指向glTF模型資源的uri路徑。

var viewer = new Cesium.Viewer('cesiumContainer');
var entity = viewer.entities.add({
    position : Cesium.Cartesian3.fromDegrees(-123.0744619, 44.0503706),
    model : {
        uri : '../../../../Apps/SampleData/models/GroundVehicle/GroundVehicle.glb'
    }
});
viewer.trackedEntity = entity;

卡車模型

你可以配置一個 scale 屬性,它將等比例縮放模型。也可以配置一個 minimumPixelSize 屬性,它保證距離模型很遠的時候,模型不會小于設定的大小。
默認,模型向右朝向東方。可以通過 Entity.orientation 的屬性設定一個 四元數Quaternion。這個比前面只用位置的示例更麻煩一些,讓我們設定一下模型的 heading, pitch, roll。把下面代碼拷貝到 Sandcastle,修改一下值 可以查看具體的效果。

var viewer = new Cesium.Viewer('cesiumContainer');
var position = Cesium.Cartesian3.fromDegrees(-123.0744619, 44.0503706);
var heading = Cesium.Math.toRadians(45.0);
var pitch = Cesium.Math.toRadians(15.0);
var roll = Cesium.Math.toRadians(0.0);
var orientation = Cesium.Transforms.headingPitchRollQuaternion(position, new Cesium.HeadingPitchRoll(heading, pitch, roll));

var entity = viewer.entities.add({
    position : position,
    orientation : orientation,
    model : {
        uri : '../../../../Apps/SampleData/models/GroundVehicle/GroundVehicle.glb'
    }
});
viewer.trackedEntity = entity;

因此模型需要轉為glTF格式才能在Cesium中使用。我們提供了一個 在線的轉換工具 ,你可以上傳COLLADA (dae)模型就會下載到glTF格式的。
當前Entity API還不支持模型的高級使用場景,比如模型節點的拾取或者動畫控制,不過可以使用Primitive API 實現。我們有一個單獨的教程來實現這些功能 三維模型高級教程 。未來我們肯定會增強 Entity API包含這些功能。這個高級教程包含了如何在Cesium下調試模型顯示的異常效果,所以一定要去學習它。如果你設計了自己的模型,一定要去看看我們的 建模人員 glTF 貼士.

屬性系統

到目前,我們都是設置了entity的圖形對象屬性,還沒有實際讀取過屬性。事實上,我們可能會對返回的結果感覺驚訝。回想我們第一個多邊形示例里,我們把outline屬性設置為 true 。直覺告訴我們,如果我們用日志輸出(console.log)獲取wyoming.polygon.outline的類型,將輸出 boolean

console.log(typeof wyoming.polygon.outline);

可是上述代碼的輸出實際是 object。因為 outline 不是一個簡單的布爾類型,而是一個ConstantProperty類的實例。實時上,這個教程整個使用的一種叫隱形屬性轉換的簡略形式來設置屬性,它會自動的使用原始值創建一個對應的 ConstantProperty 類實例。如果沒有這種簡略形式,我們就不得不去寫一個更長的初始化示例代碼:

var wyoming = new Cesium.Entity();
wyoming.name = 'Wyoming';

var polygon = new Cesium.PolygonGraphics();
polygon.material = new Cesium.ColorMaterialProperty(Cesium.Color.RED.withAlpha(0.5));
polygon.outline = new Cesium.ConstantProperty(true);
polygon.outlineColor = new Cesium.ConstantProperty(Cesium.Color.BLACK);
wyoming.polygon = polygon;

viewer.entities.add(wyoming);

為什么 屬性是這種形式?原因很簡單,整個Entity API的屬性設計是不僅僅考慮是一個常量值,而需要設置一些隨時間變換的值。
所有的屬性類實現 Property 接口, Cesium中定義了很多種屬性類。本教程的第二部分將重點關注屬性系統,使用它去創建一個時間變化的動態可視化效果。 現在,我們唯一需要知道的是:為了讀取屬性的值,我們需要調用 getValue函數。所以為了獲得多邊形的outline屬性,應該寫類似下面的代碼,時間參數傳當前場景時間即可。

console.log(wyoming.polygon.outline.getValue(viewer.clock.currentTime));

嚴格來說,如果我們明確知道正在讀取一個 ConstantProperty的值,那么可以不需要傳遞時間參數。但是明確指定時間參數是個慣例。

接下來干什么

我們勉強學習了Cesium加載空間數據可視化的一點皮毛,但是我們已經解鎖了一個巨大的可能性。等待這個教程第二部分的同時,或許可以學習下Cesium對 影像圖層 或者 地形和水面的支持。也可以看下所有教程列表 看看有沒有感興趣的。

中國最專業的Cesium開發者社區
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容