跨端自渲染绘制的实践与探索

在过去的大半年中,我一直投身于一个跨端自渲染项目的研发工作中,负责其中的渲染模块。现在通过这篇文章,我想记录并分享我们在这个项目中的经验和挑战,希望能为大家日常开发中的涉及到渲染相关的工作带来一些启发和帮助。

跨端自渲染的初衷

跨端自渲染项目的愿景在于构建一个后端渲染容器,旨在提供一个针对遵循W3C标准或类似前端规范的产物的统一渲染解决方案。这一容器的核心优势在于能够呈现出高性能、UI的一致性以及广泛的兼容性。

自渲染方案初步探索

自渲染项目的旅程可以追溯到2022年中旬,当时的目标是打造一个小程序的渲染端。我们采用QuickJS引擎来处理小程序视图层的DOM结构,并借助Flutter框架来完成UI的渲染工作。具体实现上,我们基于JS引擎生成的DOM指令来更新Flutter的RenderObject树,进而通过Flutter Engine来实现最终的屏幕渲染。通过在京东到家小程序上的灰度测试,我们对这一方案的运行效果和性能进行了初步的验证。详细的实践经验可以参考文献《京东小程序-跨端渲染引擎在京东到家上的实践》。然而,最终的性能表现并未达到我们的预期,Android端部分页面比预期快20%,而另一些页面则慢了30%以上,整体表现不够稳定;而在iOS端,性能大约慢了10%。内存占用方面,也超过了X5 WebView的使用量。

在深入分析后,我们发现性能瓶颈的一部分原因在于项目尚处于初期阶段,缺乏必要的优化。另一方面,JS运行时的速度较慢,以及渲染链路的复杂性也是导致性能不佳的因素。在这一链路中,JS节点需要频繁与Dart进行交互,生成DOM指令后,再异步传递给Flutter的pipeline事件循环。接着在Dart中构建Flutter RenderObject树并更新,最后通过FFI同步到Flutter Engine进行渲染。在进一步分析后,我们发现QuickJS在嵌入式设备上的性能尚可接受,但进一步优化难度较大。

因此,我们将焦点转向了Dart框架层。我们计划移除整个Flutter Dart框架层,并用C++实现一个精简版的RenderObject树结构来直接与Flutter Engine对接,从而避免了FFI通信的开销。同时,这也意味着DartVM变得多余,这一改变预期将进一步提升内存使用和执行速度。通过这一系列的调整,我们期待能够为用户带来更加流畅和高效的渲染体验。

自渲染方案进化之旅

自2023年初起,经过四个多月的不懈努力,我们成功用C++实现了Flutter Dart层框架的精简版。这一版框架整合了第三方库来解析CSS,并且摒弃了DartVM,直接与Flutter Engine的Skia绘图库对接,实现了基础的文本、图形和图片渲染,初步测试显示性能表现出色。随后的两个月,我们进一步细化了项目,完成了QuickJS的DOM和BOM交互API封装、常用CSS属性的解析与实现、HTML常用标签的功能实现、pipeline流程框架、流行的flow与flex布局属性、绘图操作、网络和本地IO操作、图片下载与缓存以及手势处理等核心功能。同时,我们还工程化封装了双端业务接入流程,使得项目准备好接入实际业务进行性能验证。

后来经过一系列精心的优化和接入工作,我们将京东超市的排行榜H5页面作为测试对象。在保持相同视觉效果的前提下,Android端的低端机型P30的最大内容绘制(LCP)时间约为2440毫秒,与原生WebView的2450毫秒相差无几。在iOS端,iPhone 14 Pro的LCP时间约为1271毫秒,相比Safari中的1121毫秒慢了大约12%。尽管仍稍逊于WebView,但与第一版相比,我们已经取得了显著的进步。

然而,我们的目标是不断突破,追求更高的首屏加载速度,力争匹敌甚至超越原生Native应用的性能。因此,我们推出了第三版方案。与之前基于浏览器内核渲染引擎的思路不同,此方案旨在实现与W3C标准兼容、支持几乎所有类Web视图框架的自绘渲染流程。虽然许多业界领先的跨端框架已经在上层进行了适应性收缩,如Weex2和Lynx,它们并非采用通用的DOM API,而是基于响应式框架来渲染,从而实现高性能。

因此,我们决定改变实现策略,采用基于最新版ReactNative的自绘渲染。简而言之,前端的RN jsbundle继续在Hermes引擎上运行,而我们在中间层截断,桥接我们自己编写的自绘动作,不再依赖RN的原生视图组件渲染。此方案的预期优点在于快速启动性能、较少的开发工作量,以及对各种转化和直接实现的RN业务产物的支持。然而,它的缺点也很明显,即可能会降低前端开发场景的通用支持性,对额外的CSS和小程序的支持存在挑战,而且在RN内部进行大幅修改可能会带来新的内存和性能问题,同时可能破坏与原生RN的兼容性。尽管如此,我们对这一新方案的潜力充满期待。

自渲染新方案

经过一个季度的开发工作,我们的跨端自渲染对接方案终于初步落地。接下来重点详细记录和阐述一下第三套自绘方案的实现过程以及遇到的挑战。

在深入自绘实现之前,有必要先了解React Native(RN)的基础绘制流程:

ReactNative 原理图

如图所示,RN从入口ReactRootView启动,接着创建ReactInstanceManager来管理实例并同步启动Hermes JS虚拟机。绑定完成后,加载JSbundle执行RunApplication,此时根据JS代码开始创建视图节点。在C++层,每个视图节点创建对应的shadowNode。所有shadowNode形成树状结构后,通过挂载操作完成布局、diff、生成指令等过程。最后,通过JNI调用原生Java层,通过FabricUIManager指派给SurfaceMountingManager来生成操作原生视图的指令集,在doFrame回调中执行,从而渲染视图。

这个过程中的关键步骤包括:

  1. 生成新树:基于旧树和更新状态,通过transaction回调事务创建新树。
  2. 布局信息计算:利用Yoga库计算每个节点的布局信息。
  3. 挂载新树:通过diff操作生成操作指令,通过共享二进制数组传递至原生侧,在doFrame中解析并创建原生视图节点。

了解了RN的渲染过程后,我们的目标是接管第三步,即绘制过程。我们计划不传递指令至原生层,而是直接使用Skia绘制新树。

在开始实施前,我们考虑了直接绘制shadow tree可能带来的问题和好处。好处是减少了指令的生成和传递,以及原生视图还原的过程,这有助于提高渲染速度。问题则可能涉及到diff操作和局部刷新的实现,这些工作可能需要在后续的渲染工作中来实现。

实施步骤

具体实施步骤如下:

首先,Skia库的集成。我们最初基于Skia的main分支进行集成,但遇到了一些问题,包括缺少必要的API和不稳定的崩溃。后来,我们转向基于Flutter分支的Skia,这使得集成过程顺利多了。

绘制过程通常分为两个阶段:一是遍历视图树以生成绘制指令,二是在系统帧回调中执行这些指令以刷新屏幕。这是一个异步过程。

我们首先准备绘制的SurfaceView,暂时使用原生的SurfaceView。在C++层获取SurfaceView实例的引用后,我们可以创建ANativeWindow和配置对象,进而获取SkBitmap对象,用SkCanvas通过SkBitmap进行绘制。

接下来是遍历视图树(shadow tree) 。我们发现可以基于最新状态的shadow tree生成所有绘制指令。因此,在挂载过程的diff步骤之前,我们插入了自己的逻辑。不再继续原生的diff操作,而是走我们自己的路径。我们从RootShadowNode节点开始,遍历shadow tree,同时使用Skia的SkCanvas指令进行绘制:在每种View中重写crossPaint方法,获取SkCanvasSkPaint对象,在节点中计算好对应的偏移和大小,从属性对象获取背景色等属性,设置给Canvas,生成SkPicture结果。注意这里的SkCanvas是新建的,没有关联到真正的window上。

最后,通过线程间的原子交换实现绘制结果SkPicture等对象数据的跨线程传递。我们实现了自己的在doFrame回调来做真正地绘制,其中使用ANativeWindow关联的SkBitmapSkCanvas绘制SkPicture,完成显示。如此,我们的初步想绘制的基础内容已经能够显示出来了,这是成功的第一步。下面是简单绘制的关键代码:

// ShadowTree.cpp // RN 的提交阶段
CommitStatus ShadowTree::tryCommit( // 提交挂载方法
    const ShadowTreeCommitTransaction &transaction,
    const CommitOptions &commitOptions) const {
  // ...
  auto newRootShadowNode = transaction(*oldRevision.rootShadowNode); // RN根据状态生成新树
  // ...
  newRootShadowNode->layoutIfNeeded(&affectedLayoutableNodes); // RN布局计算
  // ...
  newRevision = ShadowTreeRevision{
        std::move(newRootShadowNode), newRevisionNumber, telemetry}; // 新的版本
  // ...
  auto rootView = newRevision.rootShadowNode; // 拿到根节点
  rootView->crossPaintRoot(); // 走我们自绘路径
  // ...
  // mount(std::move(newRevision), commitOptions.mountSynchronously); // RN原来的挂载逻辑
  return CommitStatus::Succeeded;
}

// SkiaSurfaceView.cpp
// 自己的显示类,Java 侧创建 Surface 之后传递其引用 id 到 自己封装的 SkiaSurfaceView c++ 类中
class SkiaSurfaceView {
  void render(ANativeWindow *nativeWindow, int width, int height, float density);
  // 初步静态赋值
  static ANativeWindow *nativeWindow;
  static int width, height;
  static float density;
};

// LayoutableShadowNode.cpp
// 创建绘制区域 SkBitmap,拿到 SkCanvas 对象来递归绘制
void LayoutableShadowNode::crossPaintStart() const {
  auto layoutMetrics = getLayoutMetrics();
  Point offset = layoutMetrics.frame.origin;
  Size size = layoutMetrics.frame.size;

  ANativeWindow_acquire(SkiaSurfaceView::nativeWindow);
  
  // 通用的 NativeWindow 上屏显示逻辑
  ANativeWindow_setBuffersGeometry(SkiaSurfaceView::nativeWindow,SkiaSurfaceView::width,SkiaSurfaceView::height,WINDOW_FORMAT_RGBA_8888); // 设置窗口缓冲区的几何属性
  auto *buffer = new ANativeWindow_Buffer();
  SkBitmap bitmap;
  SkImageInfo image_info = SkImageInfo::MakeS32(buffer->width, buffer->height, SkAlphaType::kPremul_SkAlphaType);
  bitmap.setInfo(image_info, buffer->stride * 4);
  bitmap.setPixels(buffer->bits);
  SkCanvas skCanvas{bitmap};
  SkPaint skPaint;
  skCanvas.clear(SK_ColorWHITE);

  for (auto &child : getChildren()) {
    visitViewTree(1, *child, skCanvas, skPaint); // 调用节点绘制
  }

  // 解锁并提交绘图结果上屏
 ANativeWindow_unlockAndPost(SkiaSurfaceView::nativeWindow);
  ANativeWindow_release(SkiaSurfaceView::nativeWindow);
};

// 遍历树节点去调用 crossPaint 方法
void visitViewTree(int index, ShadowNode const &shadowNode, SkCanvas &canvas, SkPaint &paint) {
  shadowNode.crossPaint(canvas, paint); // 一次性遍历绘制树结构
  index++;
  for (auto &child : shadowNode.getChildren()) {
    visitViewTree(index, *child, canvas, paint);
  }
}

// ViewShadowNode.cpp
// 具体节点操作,这里绘制背景、边框等
void ViewShadowNode::crossPaint(SkCanvas &canvas, SkPaint &paint) const {
  // color
  const SharedColor &backgroundColor = getConcreteProps().backgroundColor;
  // border
  const CascadedBorderColors &borderColors = getConcreteProps().borderColors;
  const CascadedBorderRadii &borderRadii = getConcreteProps().borderRadii;
  const CascadedBorderStyles &borderStyles = getConcreteProps().borderStyles;

  float density = SkiaSurfaceView::density; // 考虑屏幕密度
  auto layoutMetrics = getLayoutMetrics();

// 计算目标的位置和宽高
  Point offset = layoutMetrics.frame.origin;
  offset = {offset.x * density, offset.y * density};
  Size size = layoutMetrics.frame.size;
  size = {size.width * density, size.height * density};
  paint.setAntiAlias(true);
  // ...
  const SkRect &rect = SkRect::MakeXYWH(offset.x, offset.y, size.width, size.height);
  canvas.translate(offset.x, offset.y);

  // 先处理 border
  if (borderColors.all.has_value() && borderStyles.all.has_value()) {
    float cornerRadius = borderRadii.all ? *borderRadii.all : 0;
    auto borderWidth = layoutMetrics.borderWidth.top * density;
    paint.setStrokeWidth(borderWidth); // default
    auto bStyle = *borderStyles.all;
    switch (bStyle) {
      case BorderStyle::Dotted:
        paint.setStyle(SkPaint::kStroke_Style);
        break;
      // ...
    }
    const SkRRect &rRect =
        SkRRect::MakeRectXY(rect, cornerRadius, cornerRadius);
    canvas.drawRRect(rRect, paint);
    // ...
  }
  // 再处理 background
  if (backgroundColor) {
    paint.setColor(*backgroundColor);
    paint.setStyle(SkPaint::kFill_Style);
    canvas.drawRect(rect, paint);
    // ...
  }
}

在完成了基础的图形显示之后,我们明白这样的实现仅仅是个起点。为了构建一个健壮的框架,我们需要进一步封装和优化:

  1. 从原生端获取窗口视图的宽高和屏幕密度:这对于适应不同屏幕尺寸和分辨率至关重要。它确保我们的绘制内容能够正确地适应用户的设备,无论是在高密度的显示屏还是在更大的屏幕上。
  2. 封装绘制上下文和Skia对象:我们创建了自定义的PaintingContext类,这个类封装了SkCanvasSkPicture的创建和使用,以及ContainerLayerPictureLayer。这样做的目的是为了简化Skia的原始操作,使得在我们的框架中绘制操作更加直观和易于管理。
// 绘制结果存储最小单元
class PictureLayer : public Layer {
 public:
  PictureLayer(sk_sp picture) : picture_(picture) {}

  void addToScene(Scene& scene) const override { // 上屏显示
    scene.surfaceCanvas_.drawPicture(picture_);
  }
 private:
  sk_sp picture_;
};

// 图层含义的表示
struct OrderLayer {
  Layer::Shared layer;
  int order;
  OrderLayer(Layer::Shared layer, int order) : layer(layer), order(order) {}
};

// 保存图层
class ContainerLayer : public Layer {
 public:
  using OrderListOfShared = facebook::butter::small_vector;
  using QueueOfLayer = std::queue;
  ContainerLayer() = default;

  void insert(const Layer::Shared &child, int order){
    int i = 0;
    while (i < children_.size() && children_[i].order < order) {
      i++;
    }
    children_.insert(children_.begin() + i, OrderLayer(child, order));
  }

  // ... 控制图层若干方法

  OrderListOfShared getChildren() const { return children_; }

  void getLayerQueue(QueueOfLayer& concurrentQueue) const {
   for (auto &orderLayer : children_) {
     if (auto containerLayer = orderLayer.layer->asContainerLayer()) {
      containerLayer->getLayerQueue(concurrentQueue);
     } else {
      concurrentQueue.push(orderLayer.layer);
     }
    }
   }
  
 private:
  OrderListOfShared children_;
};

// 新的绘制方法
void ViewShadowNode::performPaint(PaintingContext &paintingContext,const Point layerOffset) const {
  auto size = getLayoutMetrics().frame.size;
  auto offset = getLayoutMetrics().frame.origin;

  auto marginValue = optionalFloatFromYogaValue(
      getConcreteProps().yogaStyle.margin()[YGEdgeAll]);
  auto offsetAll = layerOffset;
  if (marginValue) {
    offsetAll = layerOffset + Point{marginValue.value(), marginValue.value()};
  }
  // ...
  auto paint = Paint();
  paint.setBorderColor(SK_ColorCYAN); // 需要细化
  // 获取到该 view 的背景数据
  paintingContext.getCanvas().drawRect(offset.x,offset.y,size.width,size.height,paint,getConcreteProps().shadowColor,getConcreteProps().backgroundColor,0,0,0,0);
  for (int i = 0; i < getChildren().size(); i++) {
    auto child = getChildren()[i];
    child->performPaint(paintingContext, offsetAll); // 遍历绘制
  }
}

接下来,我们着手实现了一些关键的特殊效果。首先是透明度的处理,这在Skia中通常通过设置画笔的透明度或者在图层上应用透明度蒙版来实现。透明度值是根据上层View的属性设置来获取的。下面是一个示例代码,展示了如何在Skia中应用透明度效果:

void StackingContextContainerLayer::composeToSceneCanvas(SkCanvas &canvas) const {
  // ...
  for (auto &child : children_) {
    // ...
    if (effectParams_ && effectParams_->needOpacity()) { // 绘制透明度
      canvas.saveLayerAlpha(nullptr, static_cast(effectParams_->opacity * 255));
    }
    child.second->composeToSceneCanvas(canvas); // 绘制操作
    if (effectParams_ && effectParams_->needOpacity()) {
      canvas.restore();
    }
    // ...
}

接着,我们实现了偏移的处理。由于布局计算是由Yoga库负责的,它提供了每个组件相对于其父布局的偏移,但在绘制时,我们需要基于全局坐标系统来操作。因此,我们必须进行累加计算,以确定每个组件在全局坐标系统中的确切位置。以下代码演示了如何根据Yoga计算的相对偏移来更新组件的全局坐标:

// ConcreteVieShadowNode.h
void performSelfPaint(const ParentContext &parentContext,StackingContext &stackingContext,Point layerOffset) const final { // 绘制自身
  // ...
  auto offset = this->getLayoutMetrics().frame.origin + layerOffset; // 坐标偏移累加
  paintBackground(stackingContext, offset, this->currentLevel()); // 画背景
  this->performPaint(parentContext, stackingContext, offset); // 遍历
  // ... 
}

通过这些封装和特性实现,我们的自绘框架不仅能够处理基本的图形绘制,还能够支持更复杂的视觉效果,同时保持对不同设备屏幕的适应性。这些进步为我们的跨端渲染项目奠定了基础,使其更加健壮和可扩展。

优化:层叠上下文与绘制规范的融合

在我们的初步渲染实现中,我们已经能够在屏幕上绘制简单的图形。然而,这种方法过于简单且粗糙,它没有考虑到Web标准中的块格式化上下文(Block Formatting Context, BFC)和层叠上下文(Stacking Context)等概念,结果就是渲染出的视图层级与预期的不一致。在HTML网页或React Native页面中,页面结构实际上是三维的,可以看作是一种“目录结构”的表现形式。为了理解CSS层叠上下文的表现和原理,我之前写了一篇文章探索层叠上下文:理解规则与构建实践,提供了深入的解析。在这里,我们需要重新思考和构建绘制方式。

层叠上下文特性示意图

新的绘制方式需要考虑以下几个关键点:

  1. 增加绘制的规范性,逐步对齐Web CSS规范。由于我们的出发点是React Native的视图结构,所以我们特别关注层叠上下文这一核心特性的完善。
  2. 构建了一套由PictureLayerContainerLayerStackingContextLayerStackingContext等关键类组成的数据结构,以承载遍历shadow tree后的绘制指令组织。整体结构如图所示:
    StackingContext  Layer层级示意图

对应的核心代码如下所示:

// 层叠上下文关联类
class Layer {
 public:
	// ...
  virtual void composeToSceneCanvas(SkCanvas &canvas) const {};
  virtual void translateTo(const Point &originPoint,const std::optional

实施要点

在这个过程中,我们需要注意的要点包括:

  • 区分普通元素和层叠上下文元素,以及它们的层叠等级和顺序。这是基于每个元素的属性来判断的,更多细节可以参考我之前的文章。
// 设置当前元素是否是层叠上下文元素和元素对应的层叠顺序, RN和 web标准的是不同的 
void checkCurrentLevelAndStackingContext(
      ParentContext &parentContext) const override {
    //  filter、mask、maskImage、maskBorder、clipPath、mixBlendMode、perspective、backdropFilter
    const ViewProps &props = this->getConcreteProps();
    bool isPositionElement =
        props.yogaStyle.positionType() != YGPositionTypeStatic;
    bool hasTransformProp = props.transform != Transform::Identity();
    bool hasTransitionProp = props.hasTransition();
    bool hasAnimationProp = props.hasAnimation(); 
    bool parentIsFlex = parentContext.isFlex;
    bool hasZIndex = props.zIndex.has_value();
    bool hasOpacity = props.opacity != 1.0;
    if (hasZIndex && (isPositionElement || parentIsFlex)) { // 位置元素且有显式的 z-index
      this->isStackingContext_ = true;
      this->currentLevel_ = props.zIndex.value();
      return;
    }
    if (isPositionElement) {
      if (hasOpacity) {
        this->isStackingContext_ = true;
      }
      this->currentLevel_ = StackingContextLevel::AUTO_INDEX; // 设置正确的层叠顺序
      return;
    }
    // ...
    if (hasOpacity || hasTransformProp || hasTransitionProp ||
        hasAnimationProp) {  // 特殊效果时设置正确的层叠顺序
      this->isStackingContext_ = true;
      this->currentLevel_ = StackingContextLevel::AUTO_INDEX;
      return;
    }
    // ...
    // float 忽略处理 、inline-block 、inline 具体判断
    this->isStackingContext_ = false;
    this->currentLevel_ = StackingContextLevel::BFC;
    return;
  }
  • 在递归遍历时,将元素的偏移和属于的层叠上下文对象传递下去。这需要精确的逻辑来确保正确的层叠和绘制。
  • 封装绘制动作。对于容器视图,如ScrollView,我们仅需要绘制背景、边框和装饰;对于节点视图,如TextImage等,需要有文本测量数据和图片的下载、缓存、解码逻辑。
  • 层叠上下文元素所属层的缓存复用。为了优化性能,我们可以缓存并复用已绘制的图层,特别是那些不常变化的部分。
  • 滑动裁切的实现。滑动窗口中的元素列表通常会超出父窗口大小,这需要我们实现一种裁切遮罩,以跟随滑动距离裁切掉多余的部分。

在Skia中,对于裁切和透明度等效果,需要先暂存状态,进行绘制,然后再恢复状态。绘制指令是一维的,没有插入操作,这意味着我们需要在绘制时就处理好所有层级和顺序问题,包括特殊效果的暂存和恢复。下面是绘制核心逻辑代码展示:

// 如果当前元素是容器元素,那么递归遍历其中的元素
void performChildrenPaint(
      StackingContext &stackingContext, 
      Point layerOffset, // 父元素偏移
      const std::optional

最后,我们添加了绘制日志。结合绘制过程分为树的遍历,创建各种ContainerLayerPictureLayer,形成嵌套的Layer树结构;以及在帧回调中使用顶层Layer节点遍历这棵树,并在真正的SkCanvas上重放之前保存的绘制指令。为了便于排查问题,我们基于栈结构设计了日志系统,它能层级化地显示当前的绘制结构或者上屏前的Layer结构。

通过以上的改进,我们的绘制系统不仅成功地将Web标准中的复杂概念如BFC和Stacking Context整合到了我们的跨端自渲染项目中。这一进步不仅解决了视图层级显示不一致的问题,而且提升了渲染效率和系统的可靠性。通过引入绘制日志系统,我们还增强了问题排查的能力,确保了渲染流程的透明度和可维护性。

结语

以上就是我们在跨端自绘渲染方案中所做的一些关键优化和改进。由于篇幅所限,我们未能详尽介绍项目的所有细节。例如,我们的手势与事件处理系统,高效的图片处理流程,包括下载、缓存和解码机制,以及利用底层GPU的GrBackendSurface技术等,都是确保渲染质量和效率的重要组成部分。此外,我们还建立了完善的单元测试体系,以保障代码的稳定性和可靠性。

这些技术细节同样关键,它们共同构成了我们高性能渲染解决方案的基石。我们非常欢迎同行和技术爱好者与我们联系,共同探讨和交流这些技术点。我们相信,通过开放的沟通和合作,我们能够不断进步,为用户带来更加流畅和丰富的跨平台体验。

作者:京东零售 张飞年

来源:京东云开发者社区

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值