Flutter框架分析(六)-- 布局

栏目: IOS · Android · 发布时间: 4年前

内容简介:之前的文章给大家介绍了Flutter渲染流水线的动画(animate), 构建(build)阶段。本篇文章会结合Flutter源码给大家介绍一下渲染流水线接下来的布局(layout)阶段。如同Android,iOS,h5等其他框架一样,页面在绘制之前框架需要确定页面内各个元素的位置和大小(尺寸)。对于页面内的某个元素而言,如果其包含子元素,则只需在知道子元素的尺寸之后再由父元素确定子元素在其内部的位置就完成了布局。所以只要确定了子元素的尺寸和位置,布局就完成了。Flutter框架的布局采用的是盒子约束(Bo

之前的文章给大家介绍了Flutter渲染流水线的动画(animate), 构建(build)阶段。本篇文章会结合Flutter源码给大家介绍一下渲染流水线接下来的布局(layout)阶段。

概述

如同Android,iOS,h5等其他框架一样,页面在绘制之前框架需要确定页面内各个元素的位置和大小(尺寸)。对于页面内的某个元素而言,如果其包含子元素,则只需在知道子元素的尺寸之后再由父元素确定子元素在其内部的位置就完成了布局。所以只要确定了子元素的尺寸和位置,布局就完成了。Flutter框架的布局采用的是盒子约束(Box constraints)模型。其布局流程如下图所示:

Flutter框架分析(六)-- 布局
图中的树是render tree。每个节点都是一个 RenderObject 。从根节点开始,每个父节点启动子节点的布局流程,在启动的时候会传入 Constraits ,也即“约束”。Flutter使用最多的是盒子约束(Box constraints)。盒子约束包含4个域:最大宽度( maxWidth )最小宽度( minWidth )最大高度( maxHeight )和最小高度( minHeight )。子节点布局完成以后会确定自己的尺寸( size )。 size 包含两个域:宽度( width )和高度( height )。父节点在子节点布局完成以后需要的时候可以获取子节点的尺寸( size

)整体的布局流程可以描述为一下一上,一下就是约束从上往下传递,一上是指尺寸从下往上传递。这样Flutter的布局流程只需要一趟遍历render tree即可完成。具体布局过程是如何运行的,我们通过分析源码来进一步分析一下。

分析

回顾《Flutter框架分析(四)-- Flutter框架的运行》我们知道在vsync信号到来以后渲染流水线启动,在engine回调 windowonDrawFrame() 函数。这个函数会运行Flutter的“持久帧回调”(PERSISTENT FRAME CALLBACKS)。渲染流水线的构建(build),布局(layout)和绘制(paint)阶段都是在这个回调里,WidgetsBinding.drawFrame()。这个函数是在RendererBinding初始化的时候加入到“Persistent”回调的。

void drawFrame() {
   try {
    if (renderViewElement != null)
      buildOwner.buildScope(renderViewElement);
    super.drawFrame();
    buildOwner.finalizeTree();
  } finally {
     ...
  }
}
复制代码

代码里的这一行 buildOwner.buildScope(renderViewElement) 是渲染流水线的构建(build)阶段。这部分我们在《Flutter框架分析(四)-- Flutter框架的运行》做了说明。而接下来的函数 super.drawFrame() 会走到 RendererBinding 中。

void drawFrame() {
  pipelineOwner.flushLayout();
  pipelineOwner.flushCompositingBits();
  pipelineOwner.flushPaint();
  renderView.compositeFrame(); // this sends the bits to the GPU
  pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
}

复制代码

里面的第一个调用 pipelineOwner.flushLayout() 就是本篇文章要讲的布局阶段了。好了,我们就从这里出发吧。先来看看 PiplineOwner.flushLayout()

void flushLayout() {
      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();
        }
      }
  }
复制代码

这里会遍历 dirtyNodes 数组。这个数组里放置的是需要重新做布局的 RenderObject 。遍历之前会对 dirtyNodes 数组按照其在render tree中的深度做个排序。这里的 排序 和我们在构建(build)阶段遇到的对element tree的排序一样。排序以后会优先处理上层节点。因为布局的时候会递归处理子节点,这样如果先处理上层节点的话,就避免了后续重复布局下层节点。之后就会调用 RenderObject._layoutWithoutResize() 来让节点自己做布局了。

void _layoutWithoutResize() {
    try {
      performLayout();
      markNeedsSemanticsUpdate();
    } catch (e, stack) {
      ...
    }
    _needsLayout = false;
    markNeedsPaint();
  }
复制代码

RenderObject 中,函数 performLayout() 需要其子类自行实现。因为有各种各样的布局,就需要子类个性化的实现自己的布局逻辑。在布局完成以后,会将自身的 _needsLayout 标志置为 false 。回头看一下上一个函数,在循环体里,只有 _needsLayouttrue 的情况下才会调用 _layoutWithoutResize() 。我们知道在Flutter中布局,渲染都是由 RenderObject 完成的。大部分页面元素使用的是盒子约束。 RenderObject 有个子类 RenderBox 就是处理这种布局方式的。而Flutter中大部分 Widget 最终是由 RenderBox 子类实现最终渲染的。源代码中的注释里有一句对 RenderBox 的定义

A render object in a 2D Cartesian coordinate system.

翻译过来就是一个在二维笛卡尔坐标系中的render object。每个盒子(box)都有个 size 属性。包含高度和宽度。每个盒子都有自己的坐标系,左上角为坐标为(0,0)。右下角坐标为(width, height)。

abstract class RenderBox extends RenderObject {
    ...
    Size _size;
    ...
}

复制代码

我们在写Flutter app的时候设定组件大小尺寸的时候都是在创建 Widget 的时候把尺寸或者类似居中等这样的配置传进去。例如以下这个 Widget 我们规定了它的大小是100x100;

Container(width: 100, height: 100);
复制代码

因为布局是在 RenderObject 里完成的,这里更具体的说应该是 RenderBox 。那么这个100x100的尺寸是如何传递到 RenderBox 的呢? RenderBox 又是如何做布局的呢? Container 是个 StatelessWidget 。它本身不会对应任何 RenderObject 。根据构造时传入的参数, Container 最终会返回由 AlignPaddingConstrainedBox 等组合而成的 Widget

Container({
    Key key,
    this.alignment,
    this.padding,
    Color color,
    Decoration decoration,
    this.foregroundDecoration,
    double width,
    double height,
    BoxConstraints constraints,
    this.margin,
    this.transform,
    this.child,
  }) : decoration = decoration ?? (color != null ? BoxDecoration(color: color) : null),
       constraints =
        (width != null || height != null)
          ? constraints?.tighten(width: width, height: height)
            ?? BoxConstraints.tightFor(width: width, height: height)
          : constraints,
       super(key: key);
       
  final BoxConstraints constraints;

  @override
  Widget build(BuildContext context) {
    Widget current = child;

    if (child == null && (constraints == null || !constraints.isTight)) {
      current = LimitedBox(
        maxWidth: 0.0,
        maxHeight: 0.0,
        child: ConstrainedBox(constraints: const BoxConstraints.expand()),
      );
    }

    if (alignment != null)
      current = Align(alignment: alignment, child: current);

    final EdgeInsetsGeometry effectivePadding = _paddingIncludingDecoration;
    if (effectivePadding != null)
      current = Padding(padding: effectivePadding, child: current);

    if (decoration != null)
      current = DecoratedBox(decoration: decoration, child: current);

    if (foregroundDecoration != null) {
      current = DecoratedBox(
        decoration: foregroundDecoration,
        position: DecorationPosition.foreground,
        child: current,
      );
    }

    if (constraints != null)
      current = ConstrainedBox(constraints: constraints, child: current);

    if (margin != null)
      current = Padding(padding: margin, child: current);

    if (transform != null)
      current = Transform(transform: transform, child: current);

    return current;
  }
复制代码

在本例中返回的是一个 ConstrainedBox

class ConstrainedBox extends SingleChildRenderObjectWidget {
  
  ConstrainedBox({
    Key key,
    @required this.constraints,
    Widget child,
  }) : assert(constraints != null),
       assert(constraints.debugAssertIsValid()),
       super(key: key, child: child);

  /// The additional constraints to impose on the child.
  final BoxConstraints constraints;

  @override
  RenderConstrainedBox createRenderObject(BuildContext context) {
    return RenderConstrainedBox(additionalConstraints: constraints);
  }

  @override
  void updateRenderObject(BuildContext context, RenderConstrainedBox renderObject) {
    renderObject.additionalConstraints = constraints;
  }
 
}
复制代码

而这个 Widget 对应的会创建 RenderConstrainedBox 。那么具体的布局工作就是由它来完成的,并且从上述代码可知,那个100x100的尺寸就在 constraints 里面了。

class RenderConstrainedBox extends RenderProxyBox {
  
  RenderConstrainedBox({
    RenderBox child,
    @required BoxConstraints additionalConstraints,
  }) : 
       _additionalConstraints = additionalConstraints,
       super(child);

  BoxConstraints _additionalConstraints;

  @override
  void performLayout() {
    if (child != null) {
      child.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
      size = child.size;
    } else {
      size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
    }
  }
}
复制代码

RenderConstrainedBox 继承自 RenderProxyBox 。而 RenderProxyBox 则又继承自 RenderBox

在这里我们看到了 performLayout() 的实现。当有孩子节点的时候,这里会调用 child.layout() 请求孩子节点做布局。调用时要传入对孩子节点的约束 constraints 。这里会把100x100的约束传入。在孩子节点布局完成以后把自己的尺寸设置为孩子节点的尺寸。没有孩子节点的时候就把约束转换为尺寸设置给自己。

我们看一下 child.layout() 。这个函数在 RenderObject 类中:

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;
    }

    if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) {
      return;
    }
    _constraints = constraints;
    _relayoutBoundary = relayoutBoundary;

    if (sizedByParent) {
      try {
        performResize();
      } catch (e, stack) {
        ...
      }
    }
    try {
      performLayout();
      markNeedsSemanticsUpdate();
     
    } catch (e, stack) {
      ...
    }
    _needsLayout = false;
    markNeedsPaint();
  }
复制代码

这个函数比较长一些,也比较关键。首先做的事情是确定 relayoutBoundary 。这里面有几个条件:

  1. parentUsesSize :父组件是否需要子组件的尺寸,这是调用时候的入参,默认为 false
  2. sizedByParent :这是个 RenderObject 的属性,表示当前 RenderObject 的布局是否只受父 RenderObject 给与的约束影响。默认为 false 。子类如果需要的话可以返回 true 。比如 RenderErrorBox 。当我们的Flutter app出错的话,屏幕上显示出来的红底黄字的界面就是由它来渲染的。
  3. constraints.isTight :代表约束是否是严格约束。也就是说是否只允许一个尺寸。
  4. 最后一个条件是父亲节点是否是 RenderObject 。 在以上条件任一个满足时, relayoutBoundary 就是自己,否则取父节点的 relayoutBoundary

接下来是另一个判断,如果当前节点不需要做重新布局,约束也没有变化, relayoutBoundary 也没有变化就直接返回了。也就是说从这个节点开始,包括其下的子节点都不需要做重新布局了。这样就会有性能上的提升。

然后是另一个判断,如果 sizedByParenttrue ,会调用 performResize() 。这个函数会仅仅根据约束来计算当前 RenderObject 的尺寸。当这个函数被调用以后,通常接下来的 performLayout() 函数里不能再更改尺寸了。

performLayout() 是大部分节点做布局的地方了。不同的 RenderObject 会有不同的实现。

最后标记当前节点需要被重绘。布局过程就是这样递归进行的。从上往下一层层的叠加不同的约束,子节点根据约束来计算自己的尺寸,需要的话,父节点会在子节点布局完成以后拿到子节点的尺寸来做进一步处理。也就是我们开头说的一下一上。

调用 layout() 的时候我们需要传入约束,那么我们就来看一下这个约束是怎么回事:

abstract class Constraints {
  bool get isTight;

  bool get isNormalized;
}
复制代码

这是个抽象类,仅有两个 getterisTight 就是我们之前说的严格约束。因为Flutter中主要是盒子约束。所以我们来看一下 Constraints 的子类: BoxConstraints

BoxConstraints

class BoxConstraints extends Constraints {
  const BoxConstraints({
    this.minWidth = 0.0,
    this.maxWidth = double.infinity,
    this.minHeight = 0.0,
    this.maxHeight = double.infinity,
  });
  
  final double minWidth;
  
  final double maxWidth;

  final double minHeight;

  final double maxHeight;
  ...
 }
复制代码

盒子约束有4个属性,最大宽度,最小宽度,最大高度和最小高度。这4个属性的不同组合构成了不同的约束。

当在某一个轴方向上最大约束和最小约束是相同的,那么这个轴方向被认为是严格约束(tightly constrained)的。

BoxConstraints.tight(Size size)
    : minWidth = size.width,
      maxWidth = size.width,
      minHeight = size.height,
      maxHeight = size.height;

const BoxConstraints.tightFor({
    double width,
    double height,
  }) : minWidth = width != null ? width : 0.0,
       maxWidth = width != null ? width : double.infinity,
       minHeight = height != null ? height : 0.0,
       maxHeight = height != null ? height : double.infinity;
    
BoxConstraints tighten({ double width, double height }) {
    return BoxConstraints(minWidth: width == null ? minWidth : width.clamp(minWidth, maxWidth),
                              maxWidth: width == null ? maxWidth : width.clamp(minWidth, maxWidth),
                              minHeight: height == null ? minHeight : height.clamp(minHeight, maxHeight),
                              maxHeight: height == null ? maxHeight : height.clamp(minHeight, maxHeight));
  }

复制代码

当在某一个轴方向上最小约束是0.0,那么这个轴方向被认为是宽松约束(loose)的。

BoxConstraints.loose(Size size)
    : minWidth = 0.0,
      maxWidth = size.width,
      minHeight = 0.0,
      maxHeight = size.height;
      

  BoxConstraints loosen() {
    assert(debugAssertIsValid());
    return BoxConstraints(
      minWidth: 0.0,
      maxWidth: maxWidth,
      minHeight: 0.0,
      maxHeight: maxHeight,
    );
  }
复制代码

当某一轴方向上的最大约束的值小于 double.infinity 时,这个轴方向的约束是有限制的。

bool get hasBoundedWidth => maxWidth < double.infinity;
 
 bool get hasBoundedHeight => maxHeight < double.infinity;
复制代码

当某一轴方向上的最大约束的值等于 double.infinity 时,这个轴方向的约束是无限制的。如果最大最小约束都是 double.infinity ,这个轴方向的约束是扩展的(exbanding)。

const BoxConstraints.expand({
    double width,
    double height,
  }) : minWidth = width != null ? width : double.infinity,
       maxWidth = width != null ? width : double.infinity,
       minHeight = height != null ? height : double.infinity,
       maxHeight = height != null ? height : double.infinity;
复制代码

最后,在布局的时候节点需要把约束转换为尺寸。这里得到的尺寸被认为是满足约束的。

Size constrain(Size size) {
    Size result = Size(constrainWidth(size.width), constrainHeight(size.height));
    return result;
  }
  
  double constrainWidth([ double width = double.infinity ]) {
    return width.clamp(minWidth, maxWidth);
  }

  double constrainHeight([ double height = double.infinity ]) {
    return height.clamp(minHeight, maxHeight);
  }
复制代码

布局例子

我们知道render tree的根节点是 RenderView 。在 RendererBinding 创建 RenderView 的时候会传入一个 ViewConfiguration 类型的配置参数:

void initRenderView() {
   assert(renderView == null);
   renderView = RenderView(configuration: createViewConfiguration(), window: window);
   renderView.scheduleInitialFrame();
 }
复制代码

ViewConfiguration 定义如下,包含一个尺寸属性和一个设备像素比例属性:

@immutable
class ViewConfiguration {

  const ViewConfiguration({
    this.size = Size.zero,
    this.devicePixelRatio = 1.0,
  });

  final Size size;

  final double devicePixelRatio;
}
复制代码

ViewConfiguration 实例由函数 createViewConfiguration() 创建:

ViewConfiguration createViewConfiguration() {
    final double devicePixelRatio = window.devicePixelRatio;
    return ViewConfiguration(
      size: window.physicalSize / devicePixelRatio,
      devicePixelRatio: devicePixelRatio,
    );
  }
复制代码

可见,尺寸取的是窗口的物理像素大小再除以设备像素比例。在Nexus5上,全屏窗口的物理像素大小( window.physicalSize )是1080x1776。设备像素比例( window.devicePixelRatio )是3.0。最终 ViewConfigurationsize 属性为360x592。

那么我们来看一下 RenderView 如何做布局:

@override
  void performLayout() {
    _size = configuration.size;
    if (child != null)
      child.layout(BoxConstraints.tight(_size));
  }

复制代码

根节点根据配置的尺寸生成了一个严格的盒子约束,以Nexus5为例的话,这个约束就是最大宽度和最小宽度都是360,最大高度和最小高度都是592。在调用子节点的 layout() 的时候传入这个严格约束。

假如我们想在屏幕居中位置显示一个100x100的矩形,代码如下:

runApp(Center(child: Container(width: 100, height: 100, color: Color(0xFFFF9000),)));
复制代码

运行以后则render tree结构如下:

Flutter框架分析(六)-- 布局

RenderView 的子节点是个 RenderPositionedBox 。其布局函数如下:

@override
  void performLayout() {

    if (child != null) {
      child.layout(constraints.loosen(), parentUsesSize: true);
      size = constraints.constrain(Size(shrinkWrapWidth ? child.size.width * (_widthFactor ?? 1.0) : double.infinity,
                                            shrinkWrapHeight ? child.size.height * (_heightFactor ?? 1.0) : double.infinity));
      alignChild();
    } 
  }
复制代码

这里的 constraints 来自根节点 RenderView 。我们之前分析过,这是一个360x592的严格约束。在调用孩子节点的 layout() 时候会给孩子节点一个新的约束,这个约束是把自己的严格约束宽松以后的新约束,也就是说,给子节点的约束是[0-360]x[0-592]。并且设置了 parentUsesSizetrue

接下来就是子节点 RenderConstrainedBox 来布局了:

@override
  void performLayout() {
    if (child != null) {
      child.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
      size = child.size;
    } else {
      size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
    }
  }
复制代码

这里又会调用子节点 RenderDecoratedBox 的布局函数,给子节点的约束是啥样的呢? _additionalConstraints 来自我们给我们在 Container 中设置的100x100大小。从前述分析可知,这是个严格约束。而父节点给过来的是[0-360]x[0-592]。通过调用 enforce() 函数生成新的约束:

BoxConstraints enforce(BoxConstraints constraints) {
    return BoxConstraints(
      minWidth: minWidth.clamp(constraints.minWidth, constraints.maxWidth),
      maxWidth: maxWidth.clamp(constraints.minWidth, constraints.maxWidth),
      minHeight: minHeight.clamp(constraints.minHeight, constraints.maxHeight),
      maxHeight: maxHeight.clamp(constraints.minHeight, constraints.maxHeight),
    );
  }
复制代码

从上述代码可见,新的约束就是100x100的严格约束了。最后我们就来到了叶子节点( RenderDecoratedBox )的布局了:

@override
  void performLayout() {
    if (child != null) {
      child.layout(constraints, parentUsesSize: true);
      size = child.size;
    } else {
      performResize();
    }
  }
复制代码

因为是叶子节点,它没有孩子,所以走的是 else 分支,调用了 performResize()

@override
  void performResize() {
    size = constraints.smallest;
  }
复制代码

没有孩子的时候默认布局就是使自己在当前约束下尽可能的小。所以这里得到的尺寸就是100x100;

至此布局流程的“一下”这个过程就完成了。可见,这个过程就是父节点根据自己的配置生成给子节点的约束,然后让子节点根据父节点的约束去做布局。

“一下”做完了,那么就该“一上”了。 回到叶子节点的父节点 RenderConstrainedBox

child.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
      size = child.size;
复制代码

没干啥,把孩子的尺寸设成自己的尺寸,孩子多大我就多大。再往上,就到了 RenderPositionedBox

child.layout(constraints.loosen(), parentUsesSize: true);
      size = constraints.constrain(Size(shrinkWrapWidth ? child.size.width * (_widthFactor ?? 1.0) : double.infinity,
                                            shrinkWrapHeight ? child.size.height * (_heightFactor ?? 1.0) : double.infinity));
      alignChild();
复制代码

这里 shrinkWrapWidthshrinkWrapHeight 都是 false 。而约束是360x592的严格约束,所以最后得到的尺寸就是360x592了。而孩子节点是100x100,那就需要知道把孩子节点放在自己内部的什么位置了,所以要调用 alignChild()

void alignChild() {
    _resolve();
    final BoxParentData childParentData = child.parentData;
    childParentData.offset = _resolvedAlignment.alongOffset(size - child.size);
  }
复制代码

孩子节点在父节点内部的对齐方式由 Alignment 决定。

class Alignment extends AlignmentGeometry {
  const Alignment(this.x, this.y)
  
  final double x;

  final double y;

  @override
  double get _x => x;

  @override
  double get _start => 0.0;

  @override
  double get _y => y;

  /// The top left corner.
  static const Alignment topLeft = Alignment(-1.0, -1.0);

  /// The center point along the top edge.
  static const Alignment topCenter = Alignment(0.0, -1.0);

  /// The top right corner.
  static const Alignment topRight = Alignment(1.0, -1.0);

  /// The center point along the left edge.
  static const Alignment centerLeft = Alignment(-1.0, 0.0);

  /// The center point, both horizontally and vertically.
  static const Alignment center = Alignment(0.0, 0.0);

  /// The center point along the right edge.
  static const Alignment centerRight = Alignment(1.0, 0.0);

  /// The bottom left corner.
  static const Alignment bottomLeft = Alignment(-1.0, 1.0);

  /// The center point along the bottom edge.
  static const Alignment bottomCenter = Alignment(0.0, 1.0);

  /// The bottom right corner.
  static const Alignment bottomRight = Alignment(1.0, 1.0);

复制代码

其内部包含两个浮点型的系数。通过这两个系数的组合就可以定义出我们通用的一些对齐方式,比如左上角是 Alignment(-1.0, -1.0) 。顶部居中就是 Alignment(0.0, -1.0) 。右上角就是 Alignment(1.0, -1.0) 。我们用到的垂直水平都居中就是 Alignment(0.0, 0.0) 。那么怎么从 Alignment 来计算偏移量呢?就是通过我们在上面见到的 Alignment.alongOffset(size - child.size) 调用了。

Offset alongOffset(Offset other) {
    final double centerX = other.dx / 2.0;
    final double centerY = other.dy / 2.0;
    return Offset(centerX + x * centerX, centerY + y * centerY);
  }
复制代码

入参就是父节点的尺寸减去子节点的尺寸,也就是父节点空余的空间。分别取空余长宽然后除以2得到中值。然后每个中值在加上 Alignment 的系数乘以这个中值就得到了偏移量。是不是很巧妙?我们的例子是垂直水平都居中, xy 都是0。所以可得偏移量就是[130,246]。

回到 alignChild() ,在取得偏移量之后,父节点会通过设置 childParentData.offset 把这个偏移量保存在孩子节点那里。这个偏移量在后续的绘制流程中会被用到。

最后就回到了根节点 RenderView 。至此布局流程的“一上”也完成了。可见这个后半段流程父节点有可能根据子节点的尺寸来决定自己的尺寸,同时也有可能要根据子节点的尺寸和自己的尺寸来决定子节点在其内部的位置。

总结

本篇文章介绍了Flutter渲染流水线的布局(layout)阶段,布局(layout)阶段主要就是要掌握住“一下一上”过程,一下就是约束层层向下传递,一上就是尺寸层层向上传递。本篇并没有过多介绍各种布局的细节,大家只要掌握了布局的流程,具体哪种布局是如何实现的只需要查阅对应 RenderObject 的源码就可以了。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

互联网运营实战手册

互联网运营实战手册

李春雷 / 人民邮电出版社 / 2017-4 / 49.80元

为什么网络推广做不起来?微信文章也是套路?标题党的背后是什么?把服务器搞瘫痪的活动是怎么玩出来的?社群究竟要如何运营?数据又该如何运营?你会任务分解吗? 《互联网运营实战手册》详细剖析了网站(产品)的运营技巧与实战,涵盖实用的互联网运营方法,是作者从多年的实战中提炼出的运营心得和精华,涉及运营技巧、运营工具和运营思维方法。详细讲解了用户运营、内容运营、新媒体运营、社群运营、活动运营和数据运营......一起来看看 《互联网运营实战手册》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

MD5 加密
MD5 加密

MD5 加密工具