Skip to content

mapbox-gl | 4.1 threebox源码解析

简述

threebox 是一个在 mapbox 中使用 three.js 的工具库,它在 mapbox 1.x 版本中为 mapbox 提供了更好的三维展现能力。通过 threebox,开发者可以在 mapbox 地图上添加three场景,从而实现更加丰富的三维效果。

然而,当 mapbox 更新出球形地球功能时,threebox 并没有完全匹配这一更新,导致其在某些场景下的实用性下降。不过,在小场景下,这个工具仍然能够满足使用需求。

简单讲一下版本问题,threebox 的原作者在 mapbox 1.x 版本的基础上实现了核心功能,但后续并未继续维护。

幸运的是,jscastro76 fork 了该仓库,并继续在2.x(球形版本之前)进行维护和更新。因此,threebox 仍然是一个值得学习和使用的工具库,特别是对于那些需要在 mapbox 中实现三维效果的项目。

在本文中,将解析 threebox 的源码,了解其实现原理,包括坐标转换和矩阵转换等关键技术,同时会讲一些比较基础的东西。通过这些解析,希望能够帮助读者更好地理解 threebox 的工作机制,并在实际项目中灵活运用这一工具库。当然,由于自己学艺不精,讲述错误的还请评论更正

目的

抽离出threebox核心,剔除不必要内容

原理

threebox 核心功能是两个:相机同步和坐标转换。相机同步包括对 three.js 相机的矩阵转换和对一个 THREE.Group 的矩阵转换。通过相机同步,能够确保 three.js 场景中的相机视角与 mapbox 地图的视角保持一致。

库结构(src)

js
// src
.
├─ animation // 模型动画相关
├─ camera
│  ├─ CameraSync // 相机异步(同步)
├─ objects // 将three的一些Geometry和loader等功能集成封装
├─ utils // 常量、通用函数等
├─ three.js // three源码
├─ Threebox.js //入口,库的主体

由于本次仅讲解相机同步和坐标转换,其他的不涉及直接略过了。

测试示例

在直接进入核心前,需要有一个示例对效果进行调试,首先在mapbox中添加threebox场景,场景中添加了一个蓝色盒子

js
import mapboxgl from 'mapbox-gl'
import * as THREE from 'three'
import { Threebox } from 'threebox-plugin'
  const map = new mapboxgl.Map({
    container: 'map',
    style: 'mapbox://styles/mapbox/streets-v12',
    center: [110.5, 40],
    zoom: 14,
    pitch: 40,
    // projection: 'mercator',
  })
  const tb = (window.tb = new Threebox(
    map,
    map.getCanvas().getContext('webgl'),
    {
      defaultLights: true,
    },
  ))
  map.on('style.load', () => {
    map.addLayer({
      id: 'custom-threebox-model',
      type: 'custom',
      renderingMode: '3d',
      onAdd() {
        const geometry = new THREE.BoxGeometry(1000, 1000, 1000)
        let cube = new THREE.Mesh(geometry, new THREE.MeshPhongMaterial({ color: 'blue' }))
        cube = tb.Object3D({ obj: cube, units: 'meters' })
        cube.setCoords([110.5, 40, 0])
         tb.add(cube)
      },
      render() {
        tb.update()
      },
    })
  })

然后在自定义一个ThreeLayer图层(mapbox customLayer)

参考官方案例,将其中内容去除。

ts
export default class ThreeLayer {
  constructor() {
  }

  onAdd(map: mapboxgl.Map, gl: WebGLRenderingContext) {

  render(gl: WebGLRenderingContext, matrix: number[]) {
  }
}

完善自定义图层

初始化场景

查看threebox源码,入口文件Threebox.js 初始化时执行了init函数,这个函数主要是初始化了three的场景,然后绑定了一系列事件,事件暂且不管,先把初始化的一些内容处理一下。

js
// Threebox.js
// line 45 ~ 80
		this.options = utils._validate(options || {}, defaultOptions);

		this.map = map;
		this.map.tb = this; //[jscastro] needed if we want to queryRenderedFeatures from map.onload

		this.objects = new Objects();

		this.mapboxVersion = parseFloat(this.map.version);

		// Set up a THREE.js scene
		this.renderer = new THREE.WebGLRenderer({
			alpha: true,
			antialias: true,
			preserveDrawingBuffer: options.preserveDrawingBuffer,
			canvas: map.getCanvas(),
			context: glContext
		});

		this.renderer.setPixelRatio(window.devicePixelRatio);
		this.renderer.setSize(this.map.getCanvas().clientWidth, this.map.getCanvas().clientHeight);
		this.renderer.outputEncoding = THREE.sRGBEncoding;
		this.renderer.autoClear = false;

		// [jscastro] set labelRendered
		this.labelRenderer = new LabelRenderer(this.map);

		this.scene = new THREE.Scene();
		this.world = new THREE.Group();
		this.world.name = "world";
		this.scene.add(this.world);

		this.objectsCache = new Map();
		this.zoomLayers = [];

		this.fov = this.options.fov;

上面的代码主要是初始化Three场景,将Scene、Renderer、Camera等作为属性写进类中,需要注意的是,它添加了一个Group到Scene中,并命名为world,后期添加任何Object都会添加到这里面,整理一下,在我们的代码中写入:

ts
interface LayerOption {
  map: mapboxgl.Map
  renderer?: THREE.WebGLRenderer
  helper?: boolean
}
const defaultLayerOption: any = {
  map: null,
  renderer: null,
}
export default class SThreeLayer {
  option: LayerOption
  renderer?: THREE.WebGLRenderer
  scene: THREE.Scene
  world: THREE.Group
  map: mapboxgl.Map
  camera: THREE.PerspectiveCamera
  id: string
  type: string
  renderingMode: string
  cameraSync?: CameraSync
  constructor(options: LayerOption) {
    this.id = 'threeLayer'
    this.type = 'custom'
    this.renderingMode = '3d'
    this.option = Object.assign(defaultLayerOption, options)
    this.map = this.option.map
    this.scene = new THREE.Scene()
    this.world = new THREE.Group()
    this.scene.add(this.world)
    this.camera = new THREE.PerspectiveCamera(
      30,
      this.map.getCanvas().offsetWidth / this.map.getCanvas().offsetHeight,
      1,
      10000,
    )
  }
  onAdd(map: mapboxgl.Map, gl: WebGLRenderingContext) {
    console.log(map, gl)
    console.log(map.transform)
    if (!this.option.renderer) {
      this.renderer = new THREE.WebGLRenderer({
        canvas: this.option.map.getCanvas(),
        context: gl,
        antialias: true,
      })
    }
    else {
      this.renderer = this.option.renderer
    }
    this.renderer.setPixelRatio(window.devicePixelRatio)
    this.renderer.setSize(
      this.map.getCanvas().offsetWidth,
      this.map.getCanvas().offsetHeight,
    )
    this.renderer.autoClear = false
    if (this.option.helper) {
      const axesHelper = new THREE.AxesHelper(1000)
      axesHelper.position.set(0, 0, 0)
      this.scene.add(axesHelper)
    }
    // 光
    const directionalLight = new THREE.DirectionalLight(0xFFFFFF)
    directionalLight.position.set(0, -70, 100).normalize()
    this.scene.add(directionalLight)
    const directionalLight2 = new THREE.DirectionalLight(0xFFFFFF)
    directionalLight2.position.set(0, 70, 100).normalize()
    this.scene.add(new THREE.AmbientLight(0x666666))
    this.scene.add(directionalLight2)
  }
}

更新场景

js
// Threebox.js
// line 899 ~ 916
	update: function () {

		if (this.map.repaint) this.map.repaint = false

		var timestamp = Date.now();

		// Update any animations
		this.objects.animationManager.update(timestamp);

		this.updateLightHelper();

		// Render the scene and repaint the map
		this.renderer.resetState(); //update threejs r126
		this.renderer.render(this.scene, this.camera);

		// [jscastro] Render any label
		this.labelRenderer.render(this.scene, this.camera);
		if (this.options.passiveRendering === false) this.map.triggerRepaint();
	},

初始化以后,再看看每次渲染更新的代码,并没有太特殊的操作,写入我们的代码中:

ts
export default class SThreeLayer {
  render(gl: WebGLRenderingContext, matrix: number[]) {
    if (this.renderer) {
      this.renderer.resetState()
      this.renderer.render(this.scene, this.camera)
    }
    this.map.triggerRepaint()
  }
}

矩阵转换

常量

矩阵变换中,用到了很多常量,这部分代码在utils/constants.js中。

写人自己的代码中:

ts
const WORLD_SIZE = 1024000 // TILE_SIZE * 2000
const FOV_ORTHO = 0.1 / 180 * Math.PI // Mapbox doesn't accept 0 as FOV
const FOV = Math.atan(3 / 4) // from Mapbox https://github.com/mapbox/mapbox-gl-js/blob/main/src/geo/transform.js#L93
// 平均半径
const EARTH_RADIUS = 6371008.8 // from Mapbox  https://github.com/mapbox/mapbox-gl-js/blob/0063cbd10a97218fb6a0f64c99bf18609b918f4c/src/geo/lng_lat.js#L11
// 地球赤道周长
const EARTH_CIRCUMFERENCE_EQUATOR = 40075017 // from Mapbox https://github.com/mapbox/mapbox-gl-js/blob/0063cbd10a97218fb6a0f64c99bf18609b918f4c/src/geo/lng_lat.js#L117

export default {
  WORLD_SIZE,
  PROJECTION_WORLD_SIZE: WORLD_SIZE / (EARTH_RADIUS * Math.PI * 2),
  MERCATOR_A: EARTH_RADIUS,
  DEG2RAD: Math.PI / 180,
  RAD2DEG: 180 / Math.PI,
  EARTH_RADIUS,
  EARTH_CIRCUMFERENCE: 2 * Math.PI * EARTH_RADIUS, // 40075000, // In meters
  EARTH_CIRCUMFERENCE_EQUATOR,
  FOV_ORTHO, // closest to 0
  FOV, // Math.atan(3/4) radians. If this value is changed, FOV_DEGREES must be calculated
  FOV_DEGREES: FOV * 180 / Math.PI, // Math.atan(3/4) in degrees
  TILE_SIZE: 512,
}

初始化CameraSync

初始化场景后,现在要考虑的是Three和mapbox的相机同步了,这部分代码在 camera/CameraSync.js中。

js
// CameraSync.js
// line 9 ~ 51
function CameraSync(map, camera, world) {
    //    console.log("CameraSync constructor");
    this.map = map;
    this.camera = camera;
    this.active = true;

    this.camera.matrixAutoUpdate = false; // We're in charge of the camera now!

    // Postion and configure the world group so we can scale it appropriately when the camera zooms
    this.world = world || new THREE.Group();
    this.world.position.x = this.world.position.y = ThreeboxConstants.WORLD_SIZE / 2
    this.world.matrixAutoUpdate = false;

    // set up basic camera state
    this.state = {
        translateCenter: new THREE.Matrix4().makeTranslation(ThreeboxConstants.WORLD_SIZE / 2, -ThreeboxConstants.WORLD_SIZE / 2, 0),
        worldSizeRatio: ThreeboxConstants.TILE_SIZE / ThreeboxConstants.WORLD_SIZE,
        worldSize: ThreeboxConstants.TILE_SIZE * this.map.transform.scale
    };

    // Listen for move events from the map and update the Three.js camera
    let _this = this; // keep the function on _this
    this.map
        .on('move', function () {
            _this.updateCamera();
        })
        .on('resize', function () {
            _this.setupCamera();
        })
    this.setupCamera();
}

CameraSync.prototype = {
    setupCamera: function () {
        const t = this.map.transform;
        this.camera.aspect = t.width / t.height; //bug fixed, if aspect is not reset raycast will fail on map resize
        this.halfFov = t._fov / 2;
        this.cameraToCenterDistance = 0.5 / Math.tan(this.halfFov) * t.height;
        const maxPitch = t._maxPitch * Math.PI / 180;
        this.acuteAngle = Math.PI / 2 - maxPitch;
        this.updateCamera();
    },
}

首先是初始化一些变量,这里我会做一些删减,仅留后面会用到的

ts
export default class CameraSync {
  map: mapboxgl.Map
  camera: THREE.PerspectiveCamera
  world: THREE.Group
  state: {
    translateCenter: THREE.Matrix4
    worldSizeRatio: number
    worldSize: number
  }

  halfFov?: number
  cameraToCenterDistance?: number
  cameraTranslateZ?: THREE.Matrix4
  constructor(map: mapboxgl.Map, camera: THREE.PerspectiveCamera, world: THREE.Group) {
    this.map = map
    this.camera = camera
    this.world = world
    // 手动设置相机矩阵
    this.camera.matrixAutoUpdate = false
    this.world.position.x = this.world.position.y = Constant.WORLD_SIZE / 2
    this.world.matrixAutoUpdate = false

    // set up basic camera state
    this.state = {
      translateCenter: new THREE.Matrix4().makeTranslation(Constant.WORLD_SIZE / 2, -Constant.WORLD_SIZE / 2, 0),
      worldSizeRatio: Constant.TILE_SIZE / Constant.WORLD_SIZE,
      worldSize: Constant.TILE_SIZE * this.map.transform.scale,
    }
  }

  setupCamera() {
    const t = this.map.transform
    this.camera.aspect = t.width / t.height
    this.halfFov = t._fov / 2
    this.cameraToCenterDistance = 0.5 / Math.tan(this.halfFov) * t.height
  }
}

CameraSync 属性表

属性名类型描述
mapmapboxgl.MapMapbox 地图实例
cameraTHREE.CameraThree.js 相机实例
worldTHREE.GroupThree.js 场景中的一个组,后续以此为父
stateObject存储状态信息。
halfFovnumber半焦
cameraToCenterDistancenumber相机距离中心距离
cameraTranslateZTHREE.Matrix4相机Z轴平移值

手动设置相机矩阵

ts
// 先把Group放到中间
this.world.position.x = this.world.position.y = Constant.WORLD_SIZE / 2
// 关闭矩阵更新,手动设置
this.world.matrixAutoUpdate = false

更新相机矩阵

这一步会比较详细的讲解,所以逐行贴源码

ts
// CameraSync.js
// line 54 ~ 146
ts
// 获取mapbox地图实例转换相关
const t = this.map.transform
// 设置相机宽高比
this.camera.aspect = t.width / t.height
// 基本是固定的,000
const offset = t.centerOffset

centerOffset只有在map设置setPadding时才会产生影响,指在屏幕上设置进行偏移,一般在移动端导航时会用到,其他情况下都是0,0,0

ts
// 视角与地面的夹角(弧度),大夹角 > 90度
const groundAngle = Math.PI / 2 + t._pitch
// 视角与地面的夹角的余弦值 小夹角 < 90度
const pitchAngle = Math.cos((Math.PI / 2) - t._pitch)

mapbox最大倾角是85度

ts
// 半焦
this.halfFov = t._fov / 2
// 相机到视图中心的距离
this.cameraToCenterDistance = 0.5 / Math.tan(this.halfFov) * t.height

t.heightcanvas的高度,0.5 * t.height * tan(半焦) 获取到视图中心的距离

ts
// 瓦片尺寸 * 缩放比例
const worldSize = t.tileSize * t.scale

获取当前世界尺寸

ts
// 根据纬度计算像素与米的比例
const pixelsPerMeter = this.mercatorZfromAltitude(1, t.center.lat) * worldSize

mercatorZfromAltitude(altitude: number, lat: number) {
  return altitude / this.circumferenceAtLatitude(lat)
}
circumferenceAtLatitude(latitude: number) {
  return Constant.EARTH_CIRCUMFERENCE * Math.cos(latitude * Math.PI / 180)
}

上面这段代码和mapbox的计算方式是相同的,Constant.EARTH_CIRCUMFERENCE是使用地球平均半径计算的地球周长,Math.cos(latitude * Math.PI / 180) 先将纬度转弧度然后获取余弦值,最后乘以周长,获取到当前纬度的地球周长。

随后用1/当前纬度地球周长算出当前像素与米的比例

ts
const fovAboveCenter = t._fov * (0.5 + t.centerOffset.y / t.height)

视图中心以上的视角,加了一半的偏移,一般等于半焦

ts
const minElevationInPixels = t.elevation ? t.elevation.getMinElevationBelowMSL() * pixelsPerMeter : 0

mapbox farthestPixelDistanceOnPlane 计算方式相同,先获取可见区域最低点的海拔,如果不加载地形则为0

ts
const cameraToSeaLevelDistance = ((t._camera.position[2] * worldSize) - minElevationInPixels) / Math.cos(t._pitch)
const topHalfSurfaceDistance = Math.sin(fovAboveCenter) * cameraToSeaLevelDistance / Math.sin(this.clamp(Math.PI - groundAngle - fovAboveCenter, 0.01, Math.PI - 0.01))
const furthestDistance = pitchAngle * topHalfSurfaceDistance + cameraToSeaLevelDistance

相机Z值 * 世界尺寸 - 最低海拔 再除以倾角余弦值,获取的是相机沿着视线方向到最低海拔的距离,并非垂直于地面的距离

随后根据此值计算出视锥体上半部分的表面距离,随后乘倾角余弦值,得到最终的距离

ts
const horizonDistance = cameraToSeaLevelDistance * (1 / t._horizonShift)
// 取最小值
const farZ = Math.min(furthestDistance * 1.01, horizonDistance)

horizonShift 该值调整地平线的垂直位置,mapbox 默认为0.1,且不改变。值越大,地平线越靠屏幕下侧。为1是,地平线在屏幕中间。

结合起来这是为了限制相机的远裁剪面在一个范围内

ts
// 平移
this.cameraTranslateZ = new THREE.Matrix4().makeTranslation(0, 0, this.cameraToCenterDistance)
const nz = (t.height / 50)
const nearZ = Math.max(nz * pitchAngle, nz)
const h = t.height
const w = t.width
this.camera.projectionMatrix = this.makePerspectiveMatrix(t._fov, w / h, nearZ, farZ)
this.camera.projectionMatrix.elements[8] = -offset.x * 2 / t.width
this.camera.projectionMatrix.elements[9] = offset.y * 2 / t.height

makePerspectiveMatrix(fov: number, aspect: number, near: number, far: number) {
  // 与mapbox-matrix库中的perspective方法一致
  const out = new THREE.Matrix4()
  const f = 1.0 / Math.tan(fov / 2)
  const nf = 1 / (near - far)
  // x, 0, a, 0,
  // 0, y, b, 0,
  // 0, 0, c, d,
  // 0, 0, -1, 0
  const newMatrix = [
    f / aspect,
    0,
    0,
    0,
    0,
    f,
    0,
    0,
    0,
    0,
    (far + near) * nf,
    -1,
    0,
    0,
    (2 * far * near) * nf,
    0,
  ]

  out.elements = newMatrix
  return out
}

先创建一个平移矩阵,nz这个值,是为了修复mapbox中使用deck.gl产生的深度问题,Math.max(nz * pitchAngle, nz) 这句话在我看来是不需要的,不知道threebox为什么会加这句话。

makePerspectiveMatrixmapbox-matrix库中的perspective方法一致,设置相机投影矩阵

随后将投影矩阵的第 8 个元素设置为 -offset.x * 2 / t.width,用于调整视图在 X 轴上的偏移量。 通过将 offset.x 乘以 2 / t.width,将偏移量从像素转换为归一化设备坐标(NDC)。 确保相机的投影矩阵正确考虑视图中心的偏移量,从而实现正确的视图渲染和用户交互。

ts
const cameraWorldMatrix = this.calcCameraMatrix(t._pitch, t.angle)
if (t.elevation)
  cameraWorldMatrix.elements[14] = t._camera.position[2] * t.worldSize
// 世界矩阵
this.camera.matrixWorld.copy(cameraWorldMatrix)

calcCameraMatrix(pitch: number, angle: number, trz?: THREE.Matrix4) {
  const t = this.map.transform
  const _pitch = (pitch === undefined) ? t._pitch : pitch
  const _angle = (angle === undefined) ? t.angle : angle
  const _trz = (trz === undefined) ? this.cameraTranslateZ : trz

  return new THREE.Matrix4()
    .premultiply(_trz)
    .premultiply(new THREE.Matrix4().makeRotationX(_pitch))
    .premultiply(new THREE.Matrix4().makeRotationZ(_angle))
}

为了让射线(点击)有效,需要将相机的平移和旋转放到世界矩阵中

更新世界矩阵

ts
    // 世界矩阵
    this.camera.matrixWorld.copy(cameraWorldMatrix)

    const zoomPow = t.scale * this.state.worldSizeRatio
    // Handle scaling and translation of objects in the map in the world's matrix transform, not the camera
    const scale = new THREE.Matrix4()
    const translateMap = new THREE.Matrix4()
    const rotateMap = new THREE.Matrix4()

    scale.makeScale(zoomPow, zoomPow, zoomPow)

    const x = t.point.x
    const y = t.point.y
    translateMap.makeTranslation(-x, y, 0)
    rotateMap.makeRotationZ(Math.PI)

    this.world.matrix = new THREE.Matrix4()
      .premultiply(rotateMap)
      .premultiply(this.state.translateCenter)
      .premultiply(scale)
      .premultiply(translateMap)

world设置世界矩阵,通过组合多个变换,依次应用了缩放、平移和旋转。

经过上述操作,three和mapbox的相机实现了基本的同步,接下来是对坐标的转换

坐标转换

js
// utils/utils.js
// line 98 ~ 118
projectToWorld(coords) {
  // 计算墨卡托投影
  const projected = [
    -Constant.MERCATOR_A * Constant.DEG2RAD * coords[0] * Constant.PROJECTION_WORLD_SIZE,
    -Constant.MERCATOR_A * Math.log(Math.tan((Math.PI * 0.25) + (0.5 * Constant.DEG2RAD * coords[1]))) * Constant.PROJECTION_WORLD_SIZE,
  ]

  if (!coords[2]) {
    projected.push(0)
  }
  else {
    const pixelsPerMeter = this.projectedUnitsPerMeter(coords[1])
    projected.push(coords[2] * pixelsPerMeter)
  }

  const result = new THREE.Vector3(projected[0], projected[1], projected[2])

  return result
}

其实转换的代码并不复杂,不过需要理解为什么这么算

ts
// 墨卡托投影公式
x = R * λ
y = R * ln(tan/4 + φ/2))

上述为计算公式,说先看一下mapbox如何计算的:

ts
x = (180 + lng) / 360
y = (180 - (180 / Math.PI * Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360)))) / 360

threebox计算为:

ts
x = -Constant.MERCATOR_A * Constant.DEG2RAD * coords[0] * Constant.PROJECTION_WORLD_SIZE
y = -Constant.MERCATOR_A * Math.log(Math.tan((Math.PI * 0.25) + (0.5 * Constant.DEG2RAD * coords[1]))) * Constant.PROJECTION_WORLD_SIZE

可以看出,计算原理是一样的,只是计算的结果的范围不同,对于Z值通过像素转米计算出来,最终得到转化后的坐标

替换为mapbox属性

threebox为了兼容1.x 2.x代码,很多属性都是自己在计算和管理,通过对比mapbox的实现方式,实际上很多都是从mapbox照搬过来的,并且这些属性都已经计算过了,不需要重复计算,现在我们不考虑兼容问题,可以将大部分属性和函数直接替换成mapbox的

以下为替换后的代码

ts
updateCamera() {
  const t = this.map.transform
  this.camera.aspect = t.aspect
  this.halfFov = t._fov / 2
  // 平移
  this.cameraTranslateZ = new THREE.Matrix4().makeTranslation(0, 0, t.cameraToCenterDistance)

  // 与mapbox - matrix库中的perspective方法一致
  // 投影矩阵
  this.camera.projectionMatrix = this.makePerspectiveMatrix(t._fov, t.aspect, t._nearZ, t._farZ)
  this.camera.projectionMatrix.elements[8] = -t.centerOffset.x * 2 / t.width
  this.camera.projectionMatrix.elements[9] = t.centerOffset.y * 2 / t.height
  const cameraWorldMatrix = this.calcCameraMatrix(t._pitch, t.angle)
  if (t.elevation)
    cameraWorldMatrix.elements[14] = t._camera.position[2] * t.worldSize
  this.camera.matrixWorld.copy(cameraWorldMatrix)
  const zoomPow = t.scale * this.state.worldSizeRatio
  const scale = new THREE.Matrix4()
  const translateMap = new THREE.Matrix4()
  const rotateMap = new THREE.Matrix4()
  scale.makeScale(zoomPow, zoomPow, zoomPow)

  const x = t.point.x
  const y = t.point.y
  translateMap.makeTranslation(-x, y, 0)
  rotateMap.makeRotationZ(Math.PI)

  this.world.matrix = new THREE.Matrix4()
    .premultiply(rotateMap)
    .premultiply(this.state.translateCenter)
    .premultiply(scale)
    .premultiply(translateMap)
}

总结

整体流程大概是这样,还有一些细节问题还需要思考,后续仍会反复整理这些代码,直至完全理解

更新于:

夜茶 2020 ~ 2026