1. 从“画线”说起:为什么标绘是三维GIS的基石
在三维GIS开发中,标绘功能——无论是画点、画线还是画面——看似基础,却是构建上层复杂应用不可或缺的基石。想象一下,你要在地图上规划一条管线、划定一个保护区、或者标注一条应急疏散路线,这些操作的本质,都是通过鼠标在三维球体上“画”出几何图形。今天,我们就聚焦于“画线”这个看似简单,实则暗藏玄机的操作,深入剖析如何利用Cesium的原生API
Cesium.DrawHandler
配合
Cesium.DrawMode.Line
模式来实现它。
很多刚接触Cesium三维开发的工程师,可能会觉得标绘功能很简单,不就是监听鼠标事件,然后记录坐标点,最后用
Cesium.Polyline
画出来吗?理论上没错,但实际做起来,你会发现一堆问题:鼠标在三维球体上移动时,如何精准地拾取地形或模型表面的点?如何实时预览正在绘制的线段?如何优雅地处理绘制过程中的取消、编辑和删除?如何保证绘制的线段在复杂地形上也能正确贴地或保持空间位置?
Cesium.DrawHandler
这个类,就是Cesium官方提供的一个“绘制管理器”,它封装了上述所有繁琐的交互逻辑和状态管理,让你能专注于业务,而不是底层的事件监听和坐标转换。
Cesium.DrawMode.Line
则是这个管理器的工作模式之一,专门用于绘制线段。它不仅仅是一个枚举值,更代表了一整套针对线段绘制的交互流程和数据处理逻辑。理解它,就等于掌握了在Cesium中实现交互式线段标绘的核心方法。接下来,我将结合我在地理信息项目中的实际经验,带你从零开始,一步步构建一个健壮、易用的线段标绘功能,并分享那些官方文档里不会写的“坑”和技巧。
2. 环境准备与核心对象初识
在开始写代码之前,我们需要确保开发环境就绪。假设你已经有一个基础的Cesium项目运行起来了。如果你还没有,最快速的方式是使用Cesium官方的
cesium-starter
模板,或者在任何Web项目中通过CDN引入Cesium。这里我推荐使用构建工具(如Vite、Webpack)进行开发,以便更好地管理依赖和模块。
# 例如,使用Vite创建一个新项目并添加Cesium
npm create vite@latest my-cesium-app -- --template vanilla
cd my-cesium-app
npm install cesium
npm install vite-plugin-cesium --save-dev # 一个方便的Vite插件
然后,在你的
vite.config.js
中配置插件,并在主HTML文件中创建Cesium Viewer。这些基础步骤网上教程很多,我就不再赘述。我们直接进入正题:理解今天的主角——
Cesium.DrawHandler
。
Cesium.DrawHandler
并不是Cesium核心库中默认导出的一个类,你需要从
cesium/Source/Widgets/DrawHandler/DrawHandler
这个路径引入。这暗示了它的定位:一个隶属于Widgets(小部件)体系的工具类,主要负责处理用户交互。它的构造函数接受两个核心参数:一个
Cesium.Viewer
实例,以及一个指定绘制模式的参数。对于画线,我们传入
Cesium.DrawMode.Line
。
import { DrawHandler, DrawMode } from 'cesium/Source/Widgets/DrawHandler/DrawHandler';
// 假设viewer是已经创建好的Cesium.Viewer实例
const drawHandler = new DrawHandler(viewer, DrawMode.LINE);
创建完
drawHandler
对象,它并不会立即开始工作。它更像一个配备了所有传感器的机器人,等待你的指令(调用方法)来激活相应的功能。这里有一个非常重要的细节:
DrawHandler
强烈依赖于Cesium的
ScreenSpaceEventHandler
。它会接管Viewer的鼠标和键盘事件监听。因此,当你激活一个
DrawHandler
时,最好确保没有其他交互逻辑(如你自己的鼠标监听事件)与其冲突,否则会导致行为异常。
注意:在较新版本的Cesium中(例如1.10x之后),
DrawHandler可能被标记为“遗留API”(Legacy API)。从网络热词中我们也看到了类似警告:“deprecation warning [legacy-js-api]: the legacy js api is deprecated and wil”。这意味着Cesium官方正在推动使用更新的、更模块化的API来替代它,例如Cesium.ScreenSpaceEventHandler配合自定义绘制逻辑,或者社区的一些工具库。但对于理解绘制原理、快速实现原型以及维护老项目来说,掌握DrawHandler依然非常有价值。本文会基于DrawHandler进行讲解,因为它概念清晰,封装完整,是学习标绘思想的绝佳范例。在文章最后,我会简要提及其替代方案和迁移思路。
3. 激活与绘制:一个完整的交互闭环
让
DrawHandler
开始工作的关键是调用其
activate()
方法。一旦激活,Cesium场景的交互控制权就部分移交给了它。
// 激活线段绘制模式
drawHandler.activate();
此时,用户将鼠标移入Cesium Canvas,光标通常会变成一个十字准星,表示进入绘制状态。整个绘制流程是这样的:
-
第一次点击(左键)
:确定线段的起点。
DrawHandler会在地球表面(或你点击的实体表面)拾取一个三维坐标点(Cartesian3)。同时,你会看到一条从起点开始,跟随鼠标移动的“橡皮筋”式的预览线。 -
移动鼠标
:预览线实时更新,终点始终是当前鼠标位置对应的三维坐标。这个预览效果是
DrawHandler自动完成的,它内部创建了一个Cesium.Polyline实例并不断更新其坐标。 -
第二次点击(左键)
:确定线段的终点。至此,一条完整的线段绘制完成。
DrawHandler会触发一个drawEvt事件,并将最终生成的Cesium.Polyline实体(Entity)通过事件回调传递出来。 - 右键点击或按下ESC键 :取消当前正在进行的绘制操作,预览线消失,状态重置。
让我们用代码来捕捉绘制完成的事件,并获取绘制结果:
// 监听绘制完成事件
drawHandler.drawEvt.addEventListener(function(result) {
// result.object 就是绘制完成的Polyline Entity
const finishedPolyline = result.object;
console.log('线段绘制完成!', finishedPolyline);
// 我们可以将其添加到Viewer的entities中以便永久显示
viewer.entities.add(finishedPolyline);
// 绘制完成后,建议停用DrawHandler,否则会一直处于绘制状态。
// 也可以选择保持激活,让用户连续绘制多条线段。
// drawHandler.deactivate();
});
这段代码构成了最基础的绘制闭环。但当你兴奋地运行起来,可能会立刻遇到第一个坑:
绘制的线段“飘”在空中,没有贴地
。这是因为默认情况下,
DrawHandler
拾取的是鼠标点击位置与三维场景中物体的交点(可能是地形、模型或默认的椭球体表面),而
Cesium.Polyline
默认的
clampToGround
属性是
false
。要解决这个问题,我们需要在事件回调中对生成的实体进行配置。
4. 深度配置:让线段更“贴合”实际需求
绘制出来的线段只是一个基础的
Cesium.Polyline
实体,我们可以通过修改其属性来满足各种业务需求。这通常在
drawEvt
事件的回调函数中进行。
4.1 解决贴地问题
要让线段贴地,需要设置
polyline.clampToGround = true
。但这里有个关键点:
必须在将实体添加到
viewer.entities
之后,Cesium才会在下一帧去计算贴地位置
。有时,如果地形数据尚未加载完毕,贴地可能不准确或失败。
drawHandler.drawEvt.addEventListener(function(result) {
const polyline = result.object;
// 设置贴地属性
polyline.polyline.clampToGround = true;
// 设置线的样式
polyline.polyline.width = 3;
polyline.polyline.material = Cesium.Color.RED.withAlpha(0.8);
// 添加描述信息
polyline.name = '我绘制的线段';
polyline.description = '这是一个测试线段';
viewer.entities.add(polyline);
// 贴地计算是异步的,如果立即读取位置可能不准确。
// 对于需要精确长度的计算,可以监听Viewer的渲染后事件。
viewer.scene.postRender.addEventListener(function() {
if (polyline.polyline.clampToGround) {
// 此时线段位置已更新,可以进行长度量算等操作
console.log('线段已贴地。');
}
});
});
4.2 自定义样式与材质
Cesium.Polyline
的
material
属性非常强大,除了纯色(
Cesium.Color
),还可以使用图片、渐变、甚至动态材质。例如,创建一个虚线:
polyline.polyline.material = new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.CYAN,
dashLength: 16.0, // 虚线段的长度
gapColor: Cesium.Color.TRANSPARENT // 间隙颜色,透明即可
});
或者,使用一个动态流动的线材质来模拟管道、光缆效果(这需要更复杂的着色器知识或使用社区材质库)。
4.3 坐标获取与数据处理
绘制完成后,我们经常需要获取线的坐标序列来进行空间分析,比如计算长度、保存到数据库等。坐标存储在
polyline.polyline.positions
中,这是一个
Cartesian3
数组。
drawHandler.drawEvt.addEventListener(function(result) {
const polyline = result.object;
viewer.entities.add(polyline);
// 立即获取的是绘制时拾取的原始坐标(可能未贴地)
const rawPositions = polyline.polyline.positions.getValue();
console.log('原始坐标(Cartesian3):', rawPositions);
// 转换为更易读的经纬度高程(Cartographic)格式
const cartographicPositions = rawPositions.map(cart3 =>
Cesium.Cartographic.fromCartesian(cart3)
);
const degreesPositions = cartographicPositions.map(cart => ({
longitude: Cesium.Math.toDegrees(cart.longitude),
latitude: Cesium.Math.toDegrees(cart.latitude),
height: cart.height
}));
console.log('经纬度高程坐标:', degreesPositions);
// 计算地理长度(测地线长度,考虑地球曲率)
let totalDistance = 0;
for (let i = 0; i < rawPositions.length - 1; i++) {
const distance = Cesium.Cartesian3.distance(rawPositions[i], rawPositions[i + 1]);
// 这是直线距离,对于长距离不准确。应使用椭球测地距离。
// totalDistance += distance;
}
// 更准确的方法:使用Cesium.EllipsoidGeodesic
if (rawPositions.length >= 2) {
const geodesic = new Cesium.EllipsoidGeodesic();
const startCarto = Cesium.Cartographic.fromCartesian(rawPositions[0]);
const endCarto = Cesium.Cartographic.fromCartesian(rawPositions[1]);
geodesic.setEndPoints(startCarto, endCarto);
totalDistance = geodesic.surfaceDistance; // 地表距离
console.log(`线段长度约为:${totalDistance.toFixed(2)} 米`);
}
});
注意:坐标转换(
Cartesian3和Cartographic)是Cesium开发中的高频操作,也是容易出错的地方。务必清楚你当前持有的坐标类型以及业务需要哪种类型。网络热词中“cesium cartesian3 和cartographic 转换”的高频出现也印证了这一点。
5. 状态管理与高级控制
一个成熟的标绘工具,不能只是“画完一条线就结束”。我们需要管理绘制的状态,提供撤销、重做、编辑、删除等功能。
DrawHandler
本身只提供最基础的绘制和取消,更高级的状态管理需要我们自己来实现。
5.1 控制绘制的开始与结束
我们通常不会让
DrawHandler
一直处于激活状态。常见的模式是,用户点击一个“绘制线段”的按钮,我们激活
DrawHandler
;绘制完成或取消后,我们停用它。
let activeDrawHandler = null;
function startDrawingLine() {
// 如果已有激活的绘制器,先停用
if (activeDrawHandler) {
activeDrawHandler.deactivate();
activeDrawHandler = null;
}
// 创建并激活新的绘制器
activeDrawHandler = new Cesium.DrawHandler(viewer, Cesium.DrawMode.LINE);
// 设置绘制完成回调
activeDrawHandler.drawEvt.addEventListener(onLineDrawn);
// 可选:设置绘制取消回调
activeDrawHandler.stopEvt.addEventListener(function() {
console.log('绘制被取消。');
activeDrawHandler = null;
});
// 激活
activeDrawHandler.activate();
// 更新UI状态,例如禁用绘制按钮,显示“正在绘制”提示
console.log('已进入线段绘制模式,左键点击绘制,右键或ESC取消。');
}
function onLineDrawn(result) {
const polyline = result.object;
// ... 配置并添加实体 ...
viewer.entities.add(polyline);
// 绘制完成后,可以选择停用,或者保持激活以连续绘制
// 这里我们选择停用,恢复到默认交互状态
if (activeDrawHandler) {
activeDrawHandler.deactivate();
activeDrawHandler = null;
}
console.log('线段绘制完成,已退出绘制模式。');
}
function cancelDrawing() {
if (activeDrawHandler) {
activeDrawHandler.deactivate();
activeDrawHandler = null;
console.log('已取消绘制。');
}
}
5.2 实现编辑与删除功能
DrawHandler
只负责“创建”,不负责“编辑”。要实现编辑(移动节点、增加节点),我们需要借助其他工具。一种常见做法是,在绘制完成后,为生成的
Polyline
实体启用
Cesium.Entity
的
property
绑定,并配合
Cesium.CallbackProperty
实现动态更新。但更直观的方式是使用Cesium的
实体编辑插件
,例如
cesium-widgets
中的
EditHandler
,或者社区库如
cesium-draw
。
这里我介绍一个基于原生API的简单思路: 选中实体后,在其坐标点位置创建可拖拽的点(Billboard),通过拖动这些点来更新线的坐标 。
-
选中线段
:通过
viewer.selectedEntityChanged事件监听选中事件。 -
创建编辑点
:当选中一个线段实体时,获取其
positions,为每个坐标创建一个可拖拽的Billboard实体。 -
监听拖拽
:为每个
Billboard绑定position属性为一个CallbackProperty,并在其回调函数中更新线段positions数组中对应的坐标。 -
更新线段
:当
CallbackProperty被触发(即点被拖动),重新计算并设置线段实体的positions。
这个过程代码量较大,涉及多个实体的联动和事件管理,是Cesium中级开发的典型挑战。它考验的是你对Cesium实体系统、属性系统和事件系统的综合理解。如果项目对标绘编辑功能要求高,强烈建议直接采用成熟的第三方标绘库,它们已经封装好了这些复杂交互。
删除功能则相对简单,直接从
viewer.entities
中移除对应的实体即可。
function deleteSelectedEntity() {
const selectedEntity = viewer.selectedEntity;
if (selectedEntity) {
viewer.entities.remove(selectedEntity);
viewer.selectedEntity = undefined; // 清除选中状态
}
}
6. 避坑指南与性能优化
在实际项目中,使用
DrawHandler
绘制线段可能会遇到一些意想不到的问题。下面是我踩过的一些坑和解决方案。
6.1 地形精度与拾取问题
当
clampToGround
为
true
时,线段的贴地精度完全取决于地形数据的精度和当前视点的地形细节层次(LOD)。在快速拖动或倾斜视角下绘制,可能会因为地形数据加载不及时,导致线段“浮”在空中或嵌入地下。
解决方案 :
- 提示用户 :在绘制提示中,建议用户等待地形加载稳定、视角接近垂直时进行精确绘制。
-
使用采样点
:对于高精度要求,可以不直接使用
DrawHandler的实时拾取点。而是在绘制完成后,获取线的坐标,然后通过Cesium.sampleTerrainMostDetailed异步函数,向服务器请求这些坐标点处的最精确地形高程,然后用新的高程值更新线的positions。 -
禁用地形
:如果业务不关心绝对高程,只关心平面路径,可以在绘制时临时关闭地形
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider(),绘制完成后再恢复。这样拾取点就在椭球面上,非常平滑。
6.2 大量线段的内存与性能
如果用户需要绘制成百上千条线段,直接将每一个都作为独立的
Entity
添加到
viewer.entities
中,会对内存和渲染性能造成压力。
解决方案 :
-
使用Primitive API
:对于静态的、样式相同的大量线段,考虑使用更低级别的
Cesium.PolylineGeometry和Cesium.PolylinePrimitive。它们比Entity API性能更高,但失去了每根线独立管理、绑定事件等便利性。你需要手动管理一个大的GeometryInstance集合。 -
数据源聚合
:使用
Cesium.CustomDataSource来管理同一类标绘数据。虽然底层还是Entity,但在数据组织和批量操作上更清晰。 - 分页与细节层次(LOD) :当线段数量极大时,需要实现数据的分页加载和基于视距的显示/隐藏,避免一次性渲染所有内容。
6.3 DrawHandler的“遗留API”警告与替代方案
正如前文提及,控制台可能会出现
DrawHandler
相关的弃用警告。Cesium官方鼓励使用更灵活的
ScreenSpaceEventHandler
来自定义绘制逻辑。这实际上是将
DrawHandler
内部做的事情拆解出来自己实现。
替代方案核心思路 :
-
创建
ScreenSpaceEventHandler,监听LEFT_DOWN,MOUSE_MOVE,LEFT_UP,RIGHT_DOWN等事件。 -
在
LEFT_DOWN时,记录起点,并创建一个用于预览的Polyline实体(clampToGround: false以便流畅跟随鼠标)。 -
在
MOUSE_MOVE时,通过viewer.scene.pickPosition拾取当前鼠标位置,并更新预览线的终点坐标。 -
在
LEFT_UP时,确定终点,销毁预览线,创建最终的Polyline实体(可设置clampToGround: true)。 -
在
RIGHT_DOWN或ESC键时,取消绘制,销毁预览线。
这种方式代码量更大,但获得了完全的控制权,可以定制每一步的交互反馈(比如不同的鼠标样式、绘制提示音、吸附功能等)。许多功能强大的第三方标绘库底层也是基于此原理构建的。
6.4 在Vue/React等框架中的集成
在现代前端项目中,我们很少直接操作DOM,而是使用Vue、React等框架。集成Cesium和
DrawHandler
的关键在于
生命周期管理
。
-
初始化
:在组件挂载后(如Vue的
mounted, React的useEffectwith empty deps),创建Viewer和DrawHandler实例,并将其引用保存在组件实例或Ref中。 - 事件绑定 :将绘制完成、取消等事件的回调函数定义为组件的方法,以便访问组件的状态(如标绘列表)。
-
销毁
:在组件卸载前(如Vue的
beforeUnmount, React的useEffectcleanup function), 必须 调用drawHandler.destroy()和viewer.destroy()来释放资源,移除事件监听,避免内存泄漏。
// Vue 3 Composition API 示例
import { onMounted, onUnmounted, ref } from 'vue';
import * as Cesium from 'cesium';
export default {
setup() {
const viewerRef = ref(null);
let viewer = null;
let drawHandler = null;
onMounted(() => {
viewer = new Cesium.Viewer('cesiumContainer');
viewerRef.value = viewer;
// 初始化DrawHandler的逻辑可以放在一个方法里,由按钮触发
});
const startDraw = () => {
if (drawHandler) {
drawHandler.deactivate();
drawHandler.destroy();
}
drawHandler = new Cesium.DrawHandler(viewer, Cesium.DrawMode.LINE);
drawHandler.drawEvt.addEventListener(onDrawComplete);
drawHandler.activate();
};
const onDrawComplete = (result) => {
const entity = result.object;
// ... 配置entity ...
viewer.entities.add(entity);
// 更新组件内的标绘列表数据
};
onUnmounted(() => {
// 关键:清理资源
if (drawHandler) {
drawHandler.deactivate();
drawHandler.destroy();
}
if (viewer) {
viewer.destroy();
}
});
return { startDraw };
}
};
7. 从线段到面:DrawMode的扩展与应用
掌握了
Cesium.DrawMode.Line
,其他绘制模式就触类旁通了。
Cesium.DrawMode
提供了多种枚举:
-
Cesium.DrawMode.LINE: 绘制线段(本次重点)。 -
Cesium.DrawMode.POLYGON: 绘制多边形面。交互是多次点击确定顶点,最后双击或点击起点闭合。回调返回的是Cesium.Polygon实体。 -
Cesium.DrawMode.POLYLINE: 绘制多段线(折线)。交互是多次点击确定顶点,右键结束绘制。回调返回的是Cesium.Polyline实体。注意与LINE模式的区别,LINE是严格的两个点,POLYLINE是多个点。 -
Cesium.DrawMode.RECTANGLE: 绘制矩形。交互是点击拖动。回调返回的是Cesium.Rectangle实体。 - 等等。
它们的核心使用模式完全一致:
new DrawHandler(viewer, mode)
->
activate()
-> 监听
drawEvt
-> 在回调中处理生成的实体。不同的模式主要影响交互流程和生成的几何类型。
例如,要实现一个简单的“绘制矩形”工具,只需将模式改为
DrawMode.RECTANGLE
,并在回调中处理
Rectangle
实体,设置其边框和填充材质即可。这种一致性大大降低了学习成本。
8. 实战:构建一个简易的线段标绘工具
最后,我们整合以上所有知识点,构建一个功能相对完整的简易线段标绘工具。这个工具将包含:开始绘制、取消绘制、删除选中线段、显示线段长度、以及线段列表管理。
由于代码较长,这里我勾勒出核心结构和关键代码片段:
HTML结构 :包含Cesium容器和一组控制按钮。
<div id="cesiumContainer"></div>
<div id="toolbar">
<button onclick="startDrawing()">绘制线段</button>
<button onclick="cancelDrawing()">取消绘制</button>
<button onclick="deleteSelected()">删除选中</button>
<button onclick="calculateLength()">计算长度</button>
<div id="status">就绪</div>
<ul id="lineList"></ul>
</div>
JavaScript核心逻辑 :
let viewer = null;
let activeDrawHandler = null;
let lines = []; // 存储所有线段实体及信息的数组
function initViewer() {
viewer = new Cesium.Viewer('cesiumContainer', {
terrainProvider: Cesium.createWorldTerrain()
});
// 监听实体选中事件,用于高亮和删除
viewer.selectedEntityChanged.addEventListener(onEntitySelected);
}
function startDrawing() {
if (activeDrawHandler) return;
activeDrawHandler = new Cesium.DrawHandler(viewer, Cesium.DrawMode.LINE);
activeDrawHandler.drawEvt.addEventListener(onLineDrawn);
activeDrawHandler.stopEvt.addEventListener(() => {
updateStatus('绘制已取消');
activeDrawHandler = null;
});
activeDrawHandler.activate();
updateStatus('左键点击绘制起点,再次点击绘制终点。右键或ESC取消。');
}
function onLineDrawn(result) {
const lineEntity = result.object;
lineEntity.polyline.clampToGround = true;
lineEntity.polyline.width = 4;
lineEntity.polyline.material = Cesium.Color.fromRandom({alpha: 1.0});
const lineId = 'line_' + Date.now();
lineEntity.id = lineId; // 为实体设置唯一ID
viewer.entities.add(lineEntity);
// 存储线段信息
const lineInfo = {
id: lineId,
entity: lineEntity,
positions: lineEntity.polyline.positions.getValue()
};
lines.push(lineInfo);
// 更新UI列表
addLineToList(lineInfo);
// 停用绘制器
if (activeDrawHandler) {
activeDrawHandler.deactivate();
activeDrawHandler.destroy();
activeDrawHandler = null;
}
updateStatus(`线段 ${lineId} 绘制完成。`);
}
function onEntitySelected() {
const selected = viewer.selectedEntity;
// 可以在这里更新UI,显示选中线段的信息
}
function deleteSelected() {
const selected = viewer.selectedEntity;
if (selected) {
viewer.entities.remove(selected);
// 从lines数组中移除
lines = lines.filter(line => line.entity !== selected);
updateLineListUI();
viewer.selectedEntity = undefined;
updateStatus('已删除选中线段。');
}
}
function calculateLength() {
const selected = viewer.selectedEntity;
if (selected && selected.polyline) {
const positions = selected.polyline.positions.getValue();
if (positions.length === 2) {
const start = Cesium.Cartographic.fromCartesian(positions[0]);
const end = Cesium.Cartographic.fromCartesian(positions[1]);
const geodesic = new Cesium.EllipsoidGeodesic();
geodesic.setEndPoints(start, end);
const distance = geodesic.surfaceDistance;
updateStatus(`线段长度约为:${(distance/1000).toFixed(3)} 公里`);
}
}
}
// ... 其他辅助函数如 updateStatus, addLineToList, updateLineListUI ...
这个工具虽然简单,但涵盖了从交互、数据管理到简单空间分析的完整流程。你可以在此基础上,继续扩展编辑、样式修改、导入导出、持久化存储等功能。
通过以上八个部分的拆解,我们从
Cesium.DrawHandler
和
Cesium.DrawMode.Line
这个具体的API点出发,深入到了三维GIS标绘功能的交互逻辑、数据管理、性能优化和工程实践。记住,API是死的,思路是活的。理解
DrawHandler
背后“事件监听-坐标拾取-实体生成”的核心思想,即使未来切换到更底层的
ScreenSpaceEventHandler
或其它标绘库,你也能游刃有余。在实际项目中,多考虑异常情况(如网络延迟、地形缺失、用户误操作),并做好相应的状态提示和错误处理,才能打造出用户体验良好的专业级标绘功能。

997

被折叠的 条评论
为什么被折叠?



