30分钟彻底了解Flutter整个渲染流程(超详细)

从运行第一行代码出发

void main() {
  runApp(const MyApp());
}

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();
}

WidgetsFlutterBinding.ensureInitialized作用是初始化WidgetsFlutterBinding对象。

//...
 WidgetsFlutterBinding.ensureInitialized()
//..
static WidgetsBinding ensureInitialized() {
    if (WidgetsBinding.instance == null)
      WidgetsFlutterBinding();
    return WidgetsBinding.instance!;
  }

WidgetsFlutterBinding初始化了一堆娃

WidgetsFlutterBinding里面继承了BindingBase。他会初始化BindingBase的构造方法。并且
并且with了很多类,而这些类都继承了BindingBase.也就间接对这些类进行了初始化工作

class WidgetsFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, ServicesBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {
//...
}

在BindingBase构造方法中,它调用了initInstances和initServiceExtensions。但是这里面只是做了一些debug模式一些初始化工作。由于WidgetsFlutterBinding所有with的类都是BindingBase的子类(如下图),这些子类如SchedulerBinding,RendererBinding,WidgetsBinding等他们都各自实现了initInstances, initServiceExtensions. 所以BindingBase构造函数本质是为了调用WidgetsFlutterBinding所有with的类里面的initInstances,initServiceExtensions
在这里插入图片描述

abstract class BindingBase {

  BindingBase() {
	//...
    initInstances();
	//...
    initServiceExtensions();
  }

 
  
  void initInstances() {
    //...
    //做debug模式的初始化活
	//...
  }
 
  
  void initServiceExtensions() {
    //...
    //做debug模式的初始化活
	//...
  }

}

三个中流砥柱

在这我们重点关注SchedulerBinding, RendererBinding, WidgetsBinding

SchedulerBinding

SchedulerBinding在这个渲染环节中主要负责请求Vsync和接收Vsync回调的工作,并且回调会消费每一帧之前的事件任务,然后进行布局绘制。后面会介绍他是怎么被执行的。(不了解什么是Vsync看看我这篇文章2分钟带你了解什么是Vsync)
它的initInstances里面只是做了SchedulerBinding的instance单例初始化.

mixin SchedulerBinding on BindingBase {
 static SchedulerBinding? get instance => _instance;
 static SchedulerBinding? _instance;
 
  void initInstances() {
    super.initInstances();
    _instance = this;
    //..
  }


  void initServiceExtensions() {
    //...
    //做debug模式的初始化活
	//...
  }
 
 //申请vsync
 void scheduleFrame() {
    //...
    ensureFrameCallbacksRegistered();
    window.scheduleFrame();
 }

  //下面的CALLBACK,每一帧都会在UI绘制之前执行
  void ensureFrameCallbacksRegistered() {
    //执行里面scheduleFrameCallback注册的回调,动画之类的事件
    window.onBeginFrame ??= _handleBeginFrame;
    //执行addPersistentFrameCallback和addPostFrameCallback中注册的回调
    window.onDrawFrame ??= _handleDrawFrame;
  }

void _handleBeginFrame(Duration rawTimeStamp) {
    //...
    handleBeginFrame(rawTimeStamp);
  }

  void _handleDrawFrame() {
   //...
    handleDrawFrame();
  }

RendererBinding

RendererBinding主要是负责管理渲染的职能
在RendererBinding的initInstances中,他同样会初始化RendererBinding单例instance.并且初始化PipelineOwner和RenderView. 其中PipelineOwner负责管理绘制工作,RenderView是整个App的渲染树

static RendererBinding? get instance => _instance;
static RendererBinding? _instance;

void initInstances() {
  super.initInstances();
  _instance = this;
  _pipelineOwner = PipelineOwner(
      onNeedVisualUpdate: ensureVisualUpdate,
      onSemanticsOwnerCreated: _handleSemanticsOwnerCreated,
      onSemanticsOwnerDisposed: _handleSemanticsOwnerDisposed,
    );	
  //...
  initRenderView();
  //...
  addPersistentFrameCallback(_handlePersistentFrameCallback);
}

 void initRenderView() {
    //..
    renderView = RenderView(configuration: createViewConfiguration(), window: window);
    renderView.prepareInitialFrame();
 }


void initServiceExtensions() {
    //...
    //做debug模式的初始化活
	//...
}

最后,他会调用addPersistentFrameCallback绑定绘制页面的callback.
也就是这个_handlePersistentFrameCallback被触发时候会调用drawFrame, drawFrame这个方法是对所有被标记需要刷新的页面进行布局和绘制。 这里不展开具体绘制过程。

void _handlePersistentFrameCallback(Duration timeStamp) {
	//
    drawFrame();
    //...
}

 //开始绘制
 void drawFrame() {
   //进行布局
    pipelineOwner.flushLayout();
    //进行绘制
    pipelineOwner.flushPaint();
    //...
  }

在这里插入图片描述

WidgetsBinding

WidgetsBinding主要用来挂载BuildOwner管理Element这棵树
他的initInstances里面会同样会初始化他的单例方法,并且会初始化携带BuildOwner。

  static WidgetsBinding? get instance => _instance;
  static WidgetsBinding? _instance;
  
  void initInstances() {
    super.initInstances();
    _instance = this;
 	//...
    _buildOwner = BuildOwner();
    //这个方法将会被scheduleBuildFor调用
    buildOwner!.onBuildScheduled = _handleBuildScheduled;
    //..
  }

  
  void initServiceExtensions() {
    //...
    //做debug模式的初始化活
	//...
  }

BuildOwner是整棵Element的树的管理类。每个Element都会有这个BuildOwner的唯一实例。BuildOwner在WidgetsBinding绑定了onBuildScheduled方法,也就是_handleBuildScheduled, 这个方法会调用ensureVisualUpdate,然后调用SchedulerBinding的 scheduleFrame方法,从而可以申请Vsync信号,从而获取下一帧的绘制。
onBuildScheduled这个方法将被scheduleBuildFor调用。当Element执行markNeedsBuild后就会调用BuildOwner的scheduleBuildFor调用,然后调用了onBuildScheduled
State->setState->markNeedsBuild->scheduleBuildFor->onBuildScheduled->ensureVisualUpdate->scheduleFrame

_handleBuildScheduled(){
	//....
    ensureVisualUpdate();
}
void ensureVisualUpdate() {
    switch (schedulerPhase) {
      case SchedulerPhase.idle:
      case SchedulerPhase.postFrameCallbacks:
        scheduleFrame();
        return;
      case SchedulerPhase.transientCallbacks:
      case SchedulerPhase.midFrameMicrotasks:
      case SchedulerPhase.persistentCallbacks:
        return;
    }
  }

等到下一帧到来的时候,被标记的Element就会被遍历执行渲染对象的布局和绘制。怎么被标记?看看平时的setState方法,

State

  
  void setState(VoidCallback fn) {
       _element!.markNeedsBuild();
 }
 

Element

BuildOwner? _owner;
void markNeedsBuild() {
    //...
    _dirty = true;
   owner!.scheduleBuildFor(this);
  //..
}

BuildOwner

  void scheduleBuildFor(Element element) {
  //..
   //申请Vsync
    onBuildScheduled!();
   //..
  	_dirtyElements.add(element);
  	//
    element._inDirtyList = true;
   //..
  }

首先会被标记_dirty=true代表需要被更新的对象, 然后会放到_dirtyElements里面,并且标记_inDirtyList=true已经添加到element树里面

综上所述,WidgetsFlutterBinding.ensureInitialized()做了以下几件事情:

  1. 创建个WidgetsFlutterBinding实例
  2. 初始化, SchedulerBinding, RendererBinding, WidgetsBinding 等所有单例
  3. WidgetsBinding.instance=SchedulerBinding.instance=RendererBinding.instance=WidgetsFlutterBinding()
  4. 然后执行SchedulerBinding, RendererBinding, WidgetsBinding 所有类的initInstances,initServiceExtensions方法
  5. 完成SchedulerBinding, RendererBinding, WidgetsBinding 所有相关的callback绑定,完成渲染树,Element树的管理类的初始化

接下来我们看看…scheduleAttachRootWidget(app),做了什么

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();
}

scheduleAttachRootWidgets属于WidgetsBinding的方法,调用了attachRootWidget,这个方法作用是将MyApp作为Widget树的根结点绑定到_renderViewElement这个棵Element树上,并且将渲染树也绑定到上面,由于第一次执行,renderViewElement肯定是空的,所以会触发SchedulerBinding.instance!.ensureVisualUpdate(),在上面已经提过ensureVisualUpdate这个方法,这里是第一次执行,所以他会注册_handleBeginFrame,_handleDrawFrame的回调(先记住这里注册,后面会讲解怎么被系统执行的)。然后请求Vsync,将会得到下一帧的绘制回调(注意在这里,是第一帧)

WidgetsBinding


  void scheduleAttachRootWidget(Widget rootWidget) {
    Timer.run(() {
      attachRootWidget(rootWidget);
    });
  }
 //这个方法将Widget,Render树都绑定在Element树上
 void attachRootWidget(Widget rootWidget) {
     final bool isBootstrapFrame = renderViewElement == null;
    _readyToProduceFrames = true;
    _renderViewElement = RenderObjectToWidgetAdapter<RenderBox>(
      container: renderView,
      debugShortDescription: '[root]',
      child: rootWidget,
    ).attachToRenderTree(buildOwner!, renderViewElement as RenderObjectToWidgetElement<RenderBox>?);
    //如果是第一次,这里会执行
    if (isBootstrapFrame) {
      //由于之前执行了WidgetsFlutterBinding.ensureInitialized()
      // SchedulerBinding.instance确保有实例的
      // 请求下一帧绘制,也就是第一帧绘制。
      SchedulerBinding.instance!.ensureVisualUpdate();
    }
  }

接下来是…scheduleWarmUpFrame(),这个方法是属于SchedulerBinding
主要是执行了handleBeginFrame和handleDrawFrame。上面已经讲解这2个方法的作用,
由于在RendererBinding中addPersistentFrameCallback,并且调用drawFrame方法,所以
执行handleDrawFrame会执行drawFrame这个方法,从而会布局和绘制页面。

void scheduleWarmUpFrame() {
 //...
 handleBeginFrame(null);
 //...
 handleDrawFrame();
}

 //执行里面scheduleFrameCallback注册的回调,动画之类的事件
void handleBeginFrame(Duration? rawTimeStamp) {
//...
   try {
      // TRANSIENT FRAME CALLBACKS
      _frameTimelineTask?.start('Animate', arguments: timelineArgumentsIndicatingLandmarkEvent);
      _schedulerPhase = SchedulerPhase.transientCallbacks;
      final Map<int, _FrameCallbackEntry> callbacks = _transientCallbacks;
      _transientCallbacks = <int, _FrameCallbackEntry>{};
      callbacks.forEach((int id, _FrameCallbackEntry callbackEntry) {
        if (!_removedIds.contains(id))
          _invokeFrameCallback(callbackEntry.callback, _currentFrameTimeStamp!, callbackEntry.debugStack);
      });
      _removedIds.clear();
    } finally {
      _schedulerPhase = SchedulerPhase.midFrameMicrotasks;
    }
}

//执行addPersistentFrameCallback和addPostFrameCallback中注册的回调
//
 void handleDrawFrame() {
  //...
      _schedulerPhase = SchedulerPhase.persistentCallbacks;
      for (final FrameCallback callback in _persistentCallbacks)
        _invokeFrameCallback(callback, _currentFrameTimeStamp!);

      // POST-FRAME CALLBACKS
      _schedulerPhase = SchedulerPhase.postFrameCallbacks;
      final List<FrameCallback> localPostFrameCallbacks =
          List<FrameCallback>.of(_postFrameCallbacks);
      _postFrameCallbacks.clear();
      for (final FrameCallback callback in localPostFrameCallbacks)
        _invokeFrameCallback(callback, _currentFrameTimeStamp!);
  //..

 }

综上所述,scheduleAttachRootWidget和 scheduleWarmUpFrame做如下几件事情

  1. 将所有树绑定关联起来
  2. 设置好Vsync信号下一帧回来执行的回调(后面验证 window是在哪里被执行的)
    window.onBeginFrame ??= _handleBeginFrame;
    window.onDrawFrame ??= _handleDrawFrame;
  3. 请求获取第一帧的绘制
  4. 强制执行handleBeginFrame和 handleDrawFrame

感叹,普普通通的一行runApp,会触发这么多业务,如果不是细细品尝,很难发现这些关系。

上面讲的都是如何申请Vsync,然后被动触发事件任务的执行,还有布局的绘制工作,那么接下来需要串通的是,如何给Vsync发出申请,然后原生App怎么下发Vsync给Flutter下发执行的

申请Vsync流程

我们回头看上面提到的scheduleFrame会请求Vsync这个方法,最终会执行window.scheduleFrame, scheduleFrame是在window.dart这个类里

mixin SchedulerBinding on BindingBase{
	//..
  void scheduleFrame() {
	//..
    ensureFrameCallbacksRegistered();
    window.scheduleFrame();
   //...
  }
	//..
}


window其实是FlutterWindow的引用

class FlutterWindow extends FlutterView {
//...

final PlatformDispatcher platformDispatcher;
void scheduleFrame() => platformDispatcher.scheduleFrame();
//...
}

对于PlatformDispatcher的scheduleFrame,是调用了native方法

class PlatformDispatcher{

  void scheduleFrame() native 'PlatformConfiguration_scheduleFrame';

}


接下来,我们来到flutter引擎源码, lib/ui/window/platform_configuration.cc.
他执行的是PlatformConfigurationNativeApi::ScheduleFrame.他调用了
PlatformConfigurationClientScheduleFrame方法


PlatformConfigurationClient* client_;
PlatformConfigurationClient* client() const { return client_; }

//
void PlatformConfigurationNativeApi::ScheduleFrame() {
  UIDartState::ThrowIfUIOperationsProhibited();
  UIDartState::Current()->platform_configuration()->client()->ScheduleFrame();
}


而PlatformConfigurationClient是由RuntimeController实现的,代码在runtime/runtime_controller.cc


class RuntimeController : public PlatformConfigurationClient 
  RuntimeDelegate& client_;
}

void RuntimeController::ScheduleFrame() {
  //
  client_.ScheduleFrame();
}

接着是用RuntimeDelegate进行申请,也就是引擎类,他在shell/common/engine.h这个路径,
他会调用animator_的RequestFrame,这个才是最终真正进行申请的类

class Engine final : public blink::RuntimeDelegate{
	//..
	Animator& animator_;
	//..
}

void Engine::ScheduleFrame(bool regenerate_layer_tree) {
  animator_->RequestFrame(regenerate_layer_tree);
}

代码在shell/common/animator.cc

void Animator::RequestFrame(bool regenerate_layer_tree) {
  //......
  task_runners_.GetUITaskRunner()->PostTask(//......
       frame_request_number = frame_request_number_]() {
        //......
        //申请Vsync
        self->AwaitVSync();
      });
}

我们下面来看看Animator的源码AwaitVSync,

class Animator final 
{
  std::shared_ptr<VsyncWaiter> waiter_;
}

void Animator::AwaitVSync() {
  waiter_->AsyncWaitForVsync(
      [self = weak_factory_.GetWeakPtr()](
          std::unique_ptr<FrameTimingsRecorder> frame_timings_recorder) {
        //...
        self->BeginFrame(std::move(frame_timings_recorder));
		//...
      });
}

他是委托VsyncWaiter实现,文件在shell/common/vsync_waiter.cc

void VsyncWaiter::AsyncWaitForVsync(const Callback& callback) {
  //......
  callback_ = std::move(callback);
  //......
  AwaitVSync();
}

然后点 AwaitVSync进去发现, 是空实现。头大了,找了很久发现是在shell/platform/android/vsync_waiter_android.cc里面实现的.也就是他对应在安卓的VsyncWaiterAndroid::AwaitVSync源码中


void VsyncWaiterAndroid::AwaitVSync() {
  //......
  task_runners_.GetPlatformTaskRunner()->PostTask([java_baton]() {
    JNIEnv* env = fml::jni::AttachCurrentThread();
    env->CallStaticVoidMethod(
        g_vsync_waiter_class->obj(),  
        //调用安卓的asyncWaitForVsync
        g_async_wait_for_vsync_method_,
        java_baton
    );
  });
}

VsyncWaiterAndroid::AwaitVSync它会调用安卓的Java文件FlutterJNI.java的静态方法
asyncWaitForVsync
在这里插入图片描述
asyncWaitForVsyncDelegate是个接口


 public interface AsyncWaitForVsyncDelegate {
    void asyncWaitForVsync(final long cookie);
 }

让我们看看他的实现类


// TODO(mattcarroll): add javadoc.
public class VsyncWaiter {

private final FlutterJNI.AsyncWaitForVsyncDelegate asyncWaitForVsyncDelegate =
      new FlutterJNI.AsyncWaitForVsyncDelegate() {
        @Override
        public void asyncWaitForVsync(long cookie) {
          Choreographer.getInstance()
              .postFrameCallback(
                  new Choreographer.FrameCallback() {
                    @Override
                    public void doFrame(long frameTimeNanos) {
                      long delay = System.nanoTime() - frameTimeNanos;
                      if (delay < 0) {
                        delay = 0;
                      }
                      flutterJNI.onVsync(delay, refreshPeriodNanos, cookie);
                    }
                  });
        }
      };
}      

,好家伙,原来他是用Choreographer.getInstance().postFrameCallback
这个方法会触发申请Vsync,然后收到Vsync会回调这个new Choreographer.FrameCallback.
也就是说,等Vsync下发回来的时候执行Choreographer.FrameCallback的doFrame方法。
不了解Android中Choreographer的朋友,可以阅读我这篇文章点击>>15分钟带你彻底了解App绘制流程-安卓篇

找到申请Vsync后,下一步就是把Vsync发到Flutter,执行下一帧的工作

下发Vsync

在Choreographer.FrameCallback的doFrame执行中,会调用 flutterJNI.onVsync,在这你已经猜到了,这里开始要下发Vsync给flutter了, 于是上面的waiter_->AsyncWaitForVsync就会执行回调,也就是

//...
self->BeginFrame(std::move(frame_timings_recorder));
//...

让我们一路往下看

void Animator::BeginFrame(
    std::unique_ptr<FrameTimingsRecorder> frame_timings_recorder) {
  //...
  delegate_.OnAnimatorBeginFrame(frame_target_time, frame_number);
  //...
}

void Shell::OnAnimatorBeginFrame(fml::TimePoint frame_target_time,
                                 uint64_t frame_number) {
  //...
  if (engine_) {
    engine_->BeginFrame(frame_target_time, frame_number);
  }
}

void Engine::BeginFrame(fml::TimePoint frame_time, uint64_t frame_number) {
  //..
  runtime_controller_->BeginFrame(frame_time, frame_number);
}

runtime_controller_在上面讲过,他是PlatformConfiguration的实例。
我们现在需要去flutter看看一个文件ui.dart
请添加图片描述
里面part了hooks.dart, 而hooks里面声明了很多被c++调用的代码其中有

('vm:entry-point')
void _drawFrame() {
  PlatformDispatcher.instance._drawFrame();
}

('vm:entry-point')
void _beginFrame(int microseconds, int frameNumber) {
  PlatformDispatcher.instance._beginFrame(microseconds);
  PlatformDispatcher.instance._updateFrameData(frameNumber);
}

PlatformConfiguration中,有个方法将hooks的方法做了关联,关联了_beginFrame,和_drawFrame,也就是PlatformConfiguration可以调用这个2个方法,如下


void PlatformConfiguration::DidCreateIsolate() {
  Dart_Handle library = Dart_LookupLibrary(tonic::ToDart("dart:ui"));
  //...
  begin_frame_.Set(tonic::DartState::Current(),
                   Dart_GetField(library, tonic::ToDart("_beginFrame")));
  draw_frame_.Set(tonic::DartState::Current(),
                  Dart_GetField(library, tonic::ToDart("_drawFrame")));
  //...
}

承接Vsync

接着来看看runtime_controller_的方法BeginFrame, 你会发现,这个方法其实就是调用了
hooks.dart的_beginFrame和_drawFrame方法

void PlatformConfiguration::BeginFrame(fml::TimePoint frameTime,
                                       uint64_t frame_number) {
  //......
  tonic::LogIfError(
      tonic::DartInvoke(begin_frame_.Get(), {
          Dart_NewInteger(microseconds),
          Dart_NewInteger(frame_number),
      }));
  UIDartState::Current()->FlushMicrotasksNow();
  tonic::LogIfError(tonic::DartInvokeVoid(draw_frame_.Get()));
}

_beginFrame和_drawFrame这两个方法是被由PlatformDispatcher持有的。

接着回头看看上面提到的ensureFrameCallbacksRegistered这个方法,

  void ensureFrameCallbacksRegistered() {

    window.onBeginFrame ??= _handleBeginFrame;
    window.onDrawFrame ??= _handleDrawFrame;
 }

看看window是怎么设置onBeginFrame和onDrawFrame的

window.dart


  FrameCallback? get onBeginFrame => platformDispatcher.onBeginFrame;
  set onBeginFrame(FrameCallback? callback) {
    platformDispatcher.onBeginFrame = callback;
  }
  VoidCallback? get onDrawFrame => platformDispatcher.onDrawFrame;
  set onDrawFrame(VoidCallback? callback) {
    platformDispatcher.onDrawFrame = callback;
  }

也就是说,Vsync下发回来其实就是触发window设置的回调onBeginFrame(SchedulerBinding的_handleBeginFrame)和onDrawFrame(SchedulerBinding的_handleDrawFrame).
这2个方法被回调就会进行事件任务的执行,以及布局绘制的工作。

终于,到这里所有流程都串通了.

我们重新梳理下所有流程

  1. 先初始化SchedulerBinding,RendererBinding,WidgetsBinding单例,确保不为空
  2. RendererBinding绑定persistentFrameCallback每次收到Vsync就进行布局和绘制工作
  3. 将SchedulerBinding的_handleBeginFrame,_handleDrawFrame通过window.onBeginFrame, onDrawFrame方法被绑定到platformDispatcher
  4. 初始化三棵树的绑定
  5. 申请第一个Vsync绘制第一帧
  6. 执行native方法从而让c++代码向安卓发出请求Vsync请求并绑定回调
  7. 安卓获取到Vsync后调用JNI传递Vsync给c++, 然后c++调用SchedulerBinding的_handleBeginFrame,_handleDrawFrame从而完成一帧的工作

好了,所以流程已经梳理完毕,是不是很赞?如果这篇文章对你有帮助,请关注🙏,点赞👍,收藏😋三连哦

  • 9
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Flutter渲染机制可以从源码角度来理解。Flutter渲染与两个线程直接相关,分别是UI线程和GPU线程。UI线程负责执行Dart root isolate代码,并将其转换为Layer tree。 Layer 是 Flutter Framework 中的一个重要概念,它代表了一块矩形区域,可以包含图形、文本、图片等内容。这些 Layer 最终会被提交到 Engine 中进行绘制。 Layer 的工作原理是将所有的绘制操作转化为一系列的绘制指令,然后将这些指令传递给 GPU 线程进行绘制。 Flutter Framework 中的绘制过程经过多个步骤,包括布局、绘制、合成等,最终将所有的 Layer 组合在一起形成最终的界面。通过理解 Flutter渲染原理,开发者可以更清晰地了解应用程序的渲染过程,并进行性能优化。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [Flutter渲染机制—UI线程](https://download.csdn.net/download/weixin_38550834/15446392)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* *3* [Flutter 画面渲染的全面解析](https://blog.csdn.net/chengjiamei/article/details/107974790)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值