深入Flutter的Rendering层(二)--- 布局layout与绘制paint

本文所有源码版本为Flutter 1.9.1,部分源码会删除assert和debug部分
转载请注明出处,谢谢

#0、本系列文章

  1. 深入Flutter的Rendering层(一)— 从runApp到三棵树的构建
  2. 深入Flutter的Rendering层(二)— 布局layout与绘制paint

#1、前言
上一篇文章,我们从runApp切入,分析了Flutter三棵树的构建过程,也深入了解了里面的运行机制。本文将着重分析帧绘制(drawFrame)的执行过程。其中有部分概念和逻辑会跟上一篇文章有关联。

#2、渲染原理
Flutter渲染原理

  1. 首先Flutter的Engine(C++)层会通过ScheduleFrame()来注册Vsync信号回调;而Engine(C++)层与Framework(Dart)层是经过Window类进行沟通(关于Window类上一篇有说明);
  2. 当Engine层收到Vsync信号,它会通过Window的onBeginFrame和onDrawFrame方法进行回调,此时信号已经回调到Framework层;
  3. Framework层会把我们的Widget通过build、layout、paint等处理,把最终的绘制内容画在Layer上;
  4. 然后Framework层通过Window的render方法回到了Engine层,Engine再向GPU线程提交绘制任务;最终渲染出来。

其中第3步就是本系列文章着重分析的内容。这一步还可以再细分为:

  • 将我们的Widget构建出对应的Element树和RenderObject树;(这一步在上一篇文章分析了)
  • 将RenderObject进行layout和paint,把绘制的结果输出在Layer上;这个步骤就是本文的核心分析内容;

#3、Framework层绘制
Flutter Engine收到Vsync信号会依次调用Window类的onBeginFrame和onDrawFrame;这两个接口在Framework层有对应的注册。

##3.1 SchedulerBinding.initInstances

[源码路径:flutter/lib/src/scheduler/binding.dart]
mixin SchedulerBinding on BindingBase, ServicesBinding {
  @override
  void initInstances() {
    super.initInstances();
    _instance = this;
    window.onBeginFrame = _handleBeginFrame;
    window.onDrawFrame = _handleDrawFrame;
    ...
      };
    }
  }

这个函数会在runApp的第一步WidgetsFlutterBinding.ensureInitialized()调用。可以看到收到Vsync信号后,会回调到_handleBeginFrame和_handleDrawFrame两个方法。

##3.2 _handleBeginFrame

[源码路径:flutter/lib/src/scheduler/binding.dart]
  void _handleBeginFrame(Duration rawTimeStamp) {
    if (_warmUpFrame) {    // 如果已经是在warmUpFrame过程中,则直接返回;因为warmUpFrame做的事情是一样的
      assert(!_ignoreNextEngineDrawFrame);
      _ignoreNextEngineDrawFrame = true;
      return;
    }
    handleBeginFrame(rawTimeStamp);    // 看下面
  }
[源码路径:flutter/lib/src/scheduler/binding.dart]
  void handleBeginFrame(Duration rawTimeStamp) {
    Timeline.startSync('Frame', arguments: timelineWhitelistArguments);
    _firstRawTimeStampInEpoch ??= rawTimeStamp;    // 记录一些帧绘制相关的时间
    _currentFrameTimeStamp = _adjustForEpoch(rawTimeStamp ?? _lastRawTimeStamp);
    if (rawTimeStamp != null)
      _lastRawTimeStamp = rawTimeStamp;

    assert(schedulerPhase == SchedulerPhase.idle);    // 当前阶段是SchedulerPhase.idle
    _hasScheduledFrame = false;
    try {
      // TRANSIENT FRAME CALLBACKS
      Timeline.startSync('Animate', arguments: timelineWhitelistArguments);
      _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);    // 回调_transientCallbacks
      });
      _removedIds.clear();
    } finally {
      _schedulerPhase = SchedulerPhase.midFrameMicrotasks;
    }
  }

_handleBeginFrame主要做了两件事情:记录开始绘制相关的时间戳和遍历_transientCallbacks,逐一回调;

##3.3 _handleDrawFrame

[源码路径:flutter/lib/src/scheduler/binding.dart]
  void _handleDrawFrame() {
    if (_ignoreNextEngineDrawFrame) {
      _ignoreNextEngineDrawFrame = false;
      return;
    }
    handleDrawFrame();    // 看下面
  }
[源码路径:flutter/lib/src/scheduler/binding.dart]
void handleDrawFrame() {
    assert(_schedulerPhase == SchedulerPhase.midFrameMicrotasks);
    Timeline.finishSync(); // end the "Animate" phase
    try {
      // 遍历_persistentCallbacks并进行回调
      _schedulerPhase = SchedulerPhase.persistentCallbacks;
      for (FrameCallback callback in _persistentCallbacks)
        _invokeFrameCallback(callback, _currentFrameTimeStamp);

     // 遍历_postFrameCallbacks并进行回调
      _schedulerPhase = SchedulerPhase.postFrameCallbacks;
      final List<FrameCallback> localPostFrameCallbacks =
          List<FrameCallback>.from(_postFrameCallbacks);
      _postFrameCallbacks.clear();
      for (FrameCallback callback in localPostFrameCallbacks)
        _invokeFrameCallback(callback, _currentFrameTimeStamp);
    } finally {
      _schedulerPhase = SchedulerPhase.idle;
      Timeline.finishSync(); // end the Frame
      _currentFrameTimeStamp = null;
    }
  }

_handleDrawFrame主要就是遍历_persistentCallbacks和_postFrameCallbacks,然后对里面注册的方法进行回调;
关于_persistentCallbacks和_postFrameCallbacks大家应该都不陌生,我们平时调用的WidgetsBinding.instance.addPersistentFrameCallback和WidgetsBinding.instance.addPostFrameCallback就是注册这里的回调。
恰巧的是,Flutter也注册了这些回调。

##3.4 RendererBinding.initInstances

[源码路径:flutter/lib/src/rendering/binding.dart]
mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, SemanticsBinding, HitTestable {
  @override
  void initInstances() {
    super.initInstances();
    _instance = this;
    _pipelineOwner = PipelineOwner(
      onNeedVisualUpdate: ensureVisualUpdate,
      onSemanticsOwnerCreated: _handleSemanticsOwnerCreated,
      onSemanticsOwnerDisposed: _handleSemanticsOwnerDisposed,
    );
    window
      ..onMetricsChanged = handleMetricsChanged
      ..onTextScaleFactorChanged = handleTextScaleFactorChanged
      ..onPlatformBrightnessChanged = handlePlatformBrightnessChanged
      ..onSemanticsEnabledChanged = _handleSemanticsEnabledChanged
      ..onSemanticsAction = _handleSemanticsAction;
    initRenderView();
    _handleSemanticsEnabledChanged();
    addPersistentFrameCallback(_handlePersistentFrameCallback);    // 这里注册了回调
    _mouseTracker = _createMouseTracker();
  }

  void _handlePersistentFrameCallback(Duration timeStamp) {
    drawFrame();    // 见3.4.1
  }

因此,整个流程下来,当接收到Vsync时,会调用到RendererBinding的drawFrame方法;

###3.4.1 drawFrame

[源码路径:flutter/lib/src/rendering/binding.dart]
  @protected
  void drawFrame() {
    assert(renderView != null);
    pipelineOwner.flushLayout();    // 触发RenderObject执行布局,见3.4.2
    pipelineOwner.flushCompositingBits();    //绘制之前的预处理操作,见3.4.3
    pipelineOwner.flushPaint();    // 触发RenderObject执行绘制,见3.4.4
    renderView.compositeFrame(); // 将需要绘制的比特数据发给GPU,见3.4.5
    pipelineOwner.flushSemantics(); // 发送语义化给系统,用于辅助功能等
  }

终于看到我们熟悉的pipelineOwner和renderView。关于这两个实例的构造过程请看上一篇文章。

###3.4.2 flushLayout

[源码路径:flutter/lib/src/rendering/object.dart]
 void flushLayout() {
    ...
    try {
      // TODO(ianh): assert that we're not allowing previously dirty nodes to redirty themselves
      while (_nodesNeedingLayout.isNotEmpty) {
        final List<RenderObject> dirtyNodes = _nodesNeedingLayout;
        _nodesNeedingLayout = <RenderObject>[];
        for (RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => a.depth - b.depth)) {
          if (node._needsLayout && node.owner == this)
            node._layoutWithoutResize();    // 见3.4.2.1
        }
      }
    } finally {
      ...
    }
  }

这个方法主要做的事情就是遍历_nodesNeedingLayout,调用其_layoutWithoutResize方法。那么_nodesNeedingLayout保存的是什么呢?谁触发存呢?

_nodesNeedingLayout存的都是RenderObject,主要有两个途径保存:

  • 一个是首次启动Flutter时,会把根结点RenderView存进去,具体请看上一篇文章3.3.5小节;
  • 另一个是我们的Widget如果有导致布局需要更新的情况,会触发markNeedsLayout,那么Widget对应的RenderObject就会存到_nodesNeedingLayout里面。常见的栗子,例如setState之后,Container的child结点变成了另一个Widget;

所以,现在很清晰了,只要你的Widget改动需要重新layout的,那么Widget对应的RenderObject就会调用_layoutWithoutResize方法。

####3.4.2.1 _layoutWithoutResize

[源码路径:flutter/lib/src/rendering/object.dart]
  void _layoutWithoutResize() {
    ...
    try {
      performLayout();    // 见3.4.2.2
      markNeedsSemanticsUpdate();
    } catch (e, stack) {
      _debugReportException('performLayout', e, stack);
    }
    ...
    _needsLayout = false;
    markNeedsPaint();
  }

####3.4.2.2 performLayout

[源码路径:flutter/lib/src/rendering/object.dart]
  /// Do the work of computing the layout for this render object.
  ///
  /// Do not call this function directly: call [layout] instead. This function
  /// is called by [layout] when there is actually work to be done by this
  /// render object during layout. The layout constraints provided by your
  /// parent are available via the [constraints] getter.
  ///
  /// If [sizedByParent] is true, then this function should not actually change
  /// the dimensions of this render object. Instead, that work should be done by
  /// [performResize]. If [sizedByParent] is false, then this function should
  /// both change the dimensions of this render object and instruct its children
  /// to layout.
  ///
  /// In implementing this function, you must call [layout] on each of your
  /// children, passing true for parentUsesSize if your layout information is
  /// dependent on your child's layout information. Passing true for
  /// parentUsesSize ensures that this render object will undergo layout if the
  /// child undergoes layout. Otherwise, the child can change its layout
  /// information without informing this render object.
  @protected
  void performLayout();

可以看到performLayout是一个接口方法,每个实现RenderObject都需要实现这个方法。
一般情况下,performLayout需要两件事情:

  • 如果有child结点,需要对所有child结点调用它的layout方法;
  • 把自身的大小保存到size变量里;
  • 如果有需要,需要把child结点的偏移量offset保存到parentData,这样child结点才能知道自己从哪里开始绘制;

简单来说测量和布局都需要在这里完成。

可以看看一些常用的Widget对应的RenderObject它的performLayout方法,例如Stack(RenderStack)、Row(RenderFlex)、Image(RenderImage)等。

####3.4.2.3 RenderView.performLayout

[源码路径:flutter/lib/src/rendering/view.dart]
  @override
  void performLayout() {
    assert(_rootTransform != null);
    _size = configuration.size;    // 注释①
    assert(_size.isFinite);

    if (child != null)
      child.layout(BoxConstraints.tight(_size));    // 注释②
  }

RenderView作为Render树的根结点,毫无疑问Flutter App首次启动最先调用的肯定是RenderView.performLayout。

#####注释①
这里的configuration.size就是手机屏幕大小,所以这行的意思就是RenderView测量的大小就是屏幕大小。(这里不做跟踪分析,有需要自行跟代码看看)

#####注释②
RenderView的child,根据上一篇文章分析,它就是我们写的Widget对应的RenderObject。那么这里就是调用RenderObject.layout。

####3.4.2.4 layout

[源码路径:flutter/lib/src/rendering/object.dart]
  void layout(Constraints constraints, { bool parentUsesSize = false }) {
    ...
    RenderObject relayoutBoundary;    // 注释①
    if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
      relayoutBoundary = this;
    } else {
      final RenderObject parent = this.parent;
      relayoutBoundary = parent._relayoutBoundary;
    }    // 上面这段是为了确定relayoutBoundary
    if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) {
      return;
    }
    _constraints = constraints;
    _relayoutBoundary = relayoutBoundary;
   if (sizedByParent) {    // 注释②
      try {
        performResize();
      } catch (e, stack) {
        _debugReportException('performResize', e, stack);
      }
    }
    RenderObject debugPreviousActiveLayout;
    try {
      performLayout();    // 注释③
      markNeedsSemanticsUpdate();
    } catch (e, stack) {
      _debugReportException('performLayout', e, stack);
    }
    _needsLayout = false;
    markNeedsPaint();
  }

layout方法是布局阶段最重要的方法。它是一个通用处理逻辑,每一个RenderObject不管是否自定义都是这个处理。

可以看到layout方法需要传入两个参数,第一个为constraints,即 父节点对子节点大小的限制,该值根据父节点的布局逻辑确定。另外一个参数是 parentUsesSize,该值用于确定 relayoutBoundary,该参数表示子节点布局变化是否影响父节点,如果为true,当子节点布局发生变化时父节点都会标记为需要重新布局,如果为false,则子节点布局发生变化后不会影响父节点。
(摘抄于https://book.flutterchina.club/chapter14/render_object.html

第一个参数根结点RenderView传的是BoxConstraints.tight(_size)。
我们再看看layout方法里面都做了些啥?

#####注释①
relayoutBoundary是个很牛逼的东西。从它名字就知道它的作用就是圈出需要重新布局的范围。它是怎么用的呢?看这个方法就知道了:

[源码路径:flutter/lib/src/rendering/object.dart]
void markNeedsLayout() {
    if (_needsLayout) {
      return;
    }
    if (_relayoutBoundary != this) {
      markParentNeedsLayout();    // 会调用parent.markNeedsLayout
    } else {
      _needsLayout = true;
      if (owner != null) {
        owner._nodesNeedingLayout.add(this);
        owner.requestVisualUpdate();
      }
    }
  }

这个方法上面有提过,最常见调用它的路径是,setState的时候把某个Widget的child改成别的Widget。也就是只要某个Widget的child结点改变或者其他操作导致重新布局时,就会触发调用markNeedsLayout。

这个方法的处理逻辑就是:

  1. 先判断relayoutBoundary是不是自己,如果是就把自己存到_nodesNeedingLayout,等待下一次Vsync对自己重新布局layout;
  2. 如果relayoutBoundary不是自己,那就向上找问parent要relayoutBoundary。所以这是个递归,直到找到relayoutBoundary为止。

那么这段代码作用就很明确了,那就是如果这个Widget有变动,它需要找到被它影响到的范围,而这个范围就是relayoutBoundary。然后需要对relayoutBoundary重新进行layout。

所以当我们写自定义RenderObject时,实现performLayout的时候,要想清楚parentUsesSize参数要怎么传,因为它会影响relayoutBoundary,也就是会影响重新layout的范围。原则当然是范围尽可能小,提高渲染效率。

#####注释②

sizedByParent 意为该节点的大小是否仅通过 parent 传给它的 constraints 就可以确定了,即该节点的大小与它自身的属性和其子节点无关,比如如果一个控件永远充满 parent 的大小,那么 sizedByParent就应该返回true,此时其大小在 performResize() 中就确定了,在后面的 performLayout() 方法中将不会再被修改了,这种情况下 performLayout() 只负责布局子节点。(摘抄于https://book.flutterchina.club/chapter14/render_object.html)

sizedByParent默认为false,一般是在集成RenderObject重写。当我们自定义RenderObject可以考虑是否需要把该值设为true;

#####注释③
performLayout前面一路分析下来。这里performLayout调用的就是我们写Widget对应RenderObject的performLayout。如果这个Widget有child结点,就会调用child.layout,如果没有child结点,那就会对自身做测量和布局。如此形式一个递归循环,遍历整一棵Render树。

至此,flushLayout分析完成。

###3.4.3 flushCompositingBits

[源码路径:flutter/lib/src/rendering/object.dart]
  void flushCompositingBits() {
    if (!kReleaseMode) {
      Timeline.startSync('Compositing bits');
    }
    _nodesNeedingCompositingBitsUpdate.sort((RenderObject a, RenderObject b) => a.depth - b.depth);
    for (RenderObject node in _nodesNeedingCompositingBitsUpdate) {
      if (node._needsCompositingBitsUpdate && node.owner == this)
        node._updateCompositingBits();
    }
    _nodesNeedingCompositingBitsUpdate.clear();
    if (!kReleaseMode) {
      Timeline.finishSync();
    }
  }

  void _updateCompositingBits() {
    if (!_needsCompositingBitsUpdate)
      return;
    final bool oldNeedsCompositing = _needsCompositing;
    _needsCompositing = false;
    visitChildren((RenderObject child) {
      child._updateCompositingBits();
      if (child.needsCompositing)
        _needsCompositing = true;
    });
    if (isRepaintBoundary || alwaysNeedsCompositing)
      _needsCompositing = true;
    if (oldNeedsCompositing != _needsCompositing)
      markNeedsPaint();
    _needsCompositingBitsUpdate = false;
  }

这个方法主要是做一些绘制前的预处理,检查RenderObject是否需要重绘。如果需要就调用markNeedsPaint方法。

###3.4.4 flushPaint

[源码路径:flutter/lib/src/rendering/object.dart]
void flushPaint() {
    if (!kReleaseMode) {
      Timeline.startSync('Paint', arguments: timelineWhitelistArguments);
    }
    try {
      final List<RenderObject> dirtyNodes = _nodesNeedingPaint;
      _nodesNeedingPaint = <RenderObject>[];
     // 按深度排序脏结点,最深度排第一位
      for (RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => b.depth - a.depth)) {
        if (node._needsPaint && node.owner == this) {
          if (node._layer.attached) {
            PaintingContext.repaintCompositedChild(node);    // 见3.4.4.1
          } else {
            node._skippedPaintingOnLayer();
          }
        }
      }
    } finally {
      if (!kReleaseMode) {
        Timeline.finishSync();
      }
    }
  }

####3.4.4.1 repaintCompositedChild

[源码路径:flutter/lib/src/rendering/object.dart]
 static void repaintCompositedChild(RenderObject child, { bool debugAlsoPaintedParent = false }) {
  _repaintCompositedChild(
    child,
    debugAlsoPaintedParent: debugAlsoPaintedParent,
  );
}

static void _repaintCompositedChild(
  RenderObject child, {
  bool debugAlsoPaintedParent = false,
  PaintingContext childContext,
}) {
  if (child._layer == null) {
    child._layer = OffsetLayer();
  } else {
    child._layer.removeAllChildren();
  }
  childContext ??= PaintingContext(child._layer, child.paintBounds);
  child._paintWithContext(childContext, Offset.zero);    // 见3.4.4.2
  childContext.stopRecordingIfNeeded();
}

####3.4.4.2 _paintWithContext

[源码路径:flutter/lib/src/rendering/object.dart]
  void _paintWithContext(PaintingContext context, Offset offset) {
    // If we still need layout, then that means that we were skipped in the
    // layout phase and therefore don't need painting. We might not know that
    // yet (that is, our layer might not have been detached yet), because the
    // same node that skipped us in layout is above us in the tree (obviously)
    // and therefore may not have had a chance to paint yet (since the tree
    // paints in reverse order). In particular this will happen if they have
    // a different layer, because there's a repaint boundary between us.
    if (_needsLayout)
      return;
    RenderObject debugLastActivePaint;
    _needsPaint = false;
    try {
      paint(context, offset);     // 核心方法
    } catch (e, stack) {
      _debugReportException('paint', e, stack);
    }
  }

  void paint(PaintingContext context, Offset offset) { }

_paintWithContext会调用RenderObject的paint方法。
paint方法默认是空实现,如果需要绘制内容,需要重写这个方法。
它跟performLayout方法类似,重写paint的时候,如果有child结点并且想绘制它的内容,则需要调用PaintingContext.paintChild去绘制child;否则,通过PaintingContext.canvas调用Canvas的Api去绘制自身内容;

可以看看各个RenderObject的paint实现源码,例如RenderStack、RenderImage等;

####3.4.4.3 RenderView.paint

[源码路径:flutter/lib/src/rendering/view.dart]
  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null)
      context.paintChild(child, offset);
  }

按照惯例,我们先看看根结点RenderView的paint实现。非常简单粗暴,就是只渲染child结点。这里的child就是我们写的Widget对应的RenderObject。

####3.4.4.4 PaintingContext.paintChild

[源码路径:flutter/lib/src/rendering/object.dart]
/// Paint a child [RenderObject].
  ///
  /// If the child has its own composited layer, the child will be composited
  /// into the layer subtree associated with this painting context. Otherwise,
  /// the child will be painted into the current PictureLayer for this context.
  void paintChild(RenderObject child, Offset offset) {
    if (child.isRepaintBoundary) {
      stopRecordingIfNeeded();
      _compositeChild(child, offset);
    } else {
      child._paintWithContext(this, offset);    // 递归调用child结点的_paintWithContext方法
    }
  }

这里又会调用会child结点的_paintWithContext方法,回到3.4.4.2。

###3.4.5 RenderView.compositeFrame

[源码路径:flutter/lib/src/rendering/view.dart]
  /// Uploads the composited layer tree to the engine.
  ///
  /// Actually causes the output of the rendering pipeline to appear on screen.
  void compositeFrame() {
    Timeline.startSync('Compositing', arguments: timelineWhitelistArguments);
    try {
      final ui.SceneBuilder builder = ui.SceneBuilder();
      final ui.Scene scene = layer.buildScene(builder);
      if (automaticSystemUiAdjustment)
        _updateSystemChrome();
      _window.render(scene);
      scene.dispose();
    } finally {
      Timeline.finishSync();
    }
  }

这个方法中有一个Scene对象,Scene对象是一个数据结构,保存最终渲染后的像素信息。这个方法将Canvas画好的Scene传给window.render()方法,该方法会直接将scene信息发送给Flutter engine,最终由engine将图像画在设备屏幕上。(摘抄于https://book.flutterchina.club/chapter14/flutter_app_startup.html)

###3.4.6 小结
上面整个流程下来就是每一次Vsync信号,在Dart层所做的处理。核心就是执行drawFrame方法。而drawFrame方法主要有三个步骤:

  1. 遍历需要layout的RenderObject,让它执行performLayout方法;整个调用栈是:RenderView.performLayout->child.layout->child.performLayout->child.layout->…;
  2. 遍历需要paint的RenderObject,让它执行paint方法;整个调用栈是:RenderView.paint->PaintingContext.paintChild->child.paint->PaintingContext.paintChild->…;
  3. 通过PaintingContext.canvas可以把RenderObject绘制的内容绘制到PaintingContext._currentLayer上,最终构造出Scene实例,通过Window.render方法把Scene发送给Engine层,最终由Engine将内容渲染在设备屏幕上。

#4、回顾问题
系列文章开始时提出了几个问题,经过系列文章分析后,基本上所有问题都已经有答案了:

  • 我写的Widget是怎么渲染出来呢?(上一篇有解答)

  • 为什么有时候Container是撑满父亲,有时候又不是?
    答:是否撑满父亲应该是在layout过程去处理,那只需要看Container对应的RenderObject如何layout就可以找到答案。(如果看了Container的build源码就大概能理解)

  • 我写的Widget明明这么复杂,为啥可以被频繁build重新创建,性能还这么好?(上一篇有解答)

  • 我们都知道有Widget树、Element树、RenderObject树,但是为什么要设计这么多层?Element树究竟有啥用?(上一篇有解答)

  • 那个渲染溢出(overflow)的错误提示好烦啊,它是怎么出来的?
    答:错误提示也是渲染出来,所以是在paint那里处理的。那么只要看看Row对应的RenderObject也就是RenderFlex的paint实现就能明白。

#5、参考资料

布局类组件简介
RenderObject和RenderBox
Flutter渲染机制—UI线程
深入了解Flutter界面开发

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值