摘要:在中,函數(shù)需要其子類自行實現(xiàn)。當(dāng)在某一個軸方向上最小約束是,那么這個軸方向被認(rèn)為是寬松約束的。布局例子我們知道的根節(jié)點是。
前言之前的文章給大家介紹了Flutter渲染流水線的動畫(animate), 構(gòu)建(build)階段。本篇文章會結(jié)合Flutter源碼給大家介紹一下渲染流水線接下來的布局(layout)階段。
概述如同Android,iOS,h5等其他框架一樣,頁面在繪制之前框架需要確定頁面內(nèi)各個元素的位置和大小(尺寸)。對于頁面內(nèi)的某個元素而言,如果其包含子元素,則只需在知道子元素的尺寸之后再由父元素確定子元素在其內(nèi)部的位置就完成了布局。所以只要確定了子元素的尺寸和位置,布局就完成了。Flutter框架的布局采用的是盒子約束(Box constraints)模型。其布局流程如下圖所示:
圖中的樹是render tree。每個節(jié)點都是一個RenderObject。從根節(jié)點開始,每個父節(jié)點啟動子節(jié)點的布局流程,在啟動的時候會傳入Constraits,也即“約束”。Flutter使用最多的是盒子約束(Box constraints)。盒子約束包含4個域:最大寬度(maxWidth)最小寬度(minWidth)最大高度(maxHeight)和最小高度(minHeight)。子節(jié)點布局完成以后會確定自己的尺寸(size)。size包含兩個域:寬度(width)和高度(height)。父節(jié)點在子節(jié)點布局完成以后需要的時候可以獲取子節(jié)點的尺寸(size)整體的布局流程可以描述為一下一上,一下就是約束從上往下傳遞,一上是指尺寸從下往上傳遞。這樣Flutter的布局流程只需要一趟遍歷render tree即可完成。具體布局過程是如何運行的,我們通過分析源碼來進(jìn)一步分析一下。
分析
回顧《Flutter框架分析(四)-- Flutter框架的運行》我們知道在vsync信號到來以后渲染流水線啟動,在engine回調(diào)window的onDrawFrame()函數(shù)。這個函數(shù)會運行Flutter的“持久幀回調(diào)”(PERSISTENT FRAME CALLBACKS)。渲染流水線的構(gòu)建(build),布局(layout)和繪制(paint)階段都是在這個回調(diào)里,WidgetsBinding.drawFrame()。這個函數(shù)是在RendererBinding初始化的時候加入到“Persistent”回調(diào)的。
void drawFrame() {
try {
if (renderViewElement != null)
buildOwner.buildScope(renderViewElement);
super.drawFrame();
buildOwner.finalizeTree();
} finally {
...
}
}
代碼里的這一行buildOwner.buildScope(renderViewElement)是渲染流水線的構(gòu)建(build)階段。這部分我們在《Flutter框架分析(四)-- Flutter框架的運行》做了說明。而接下來的函數(shù)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.
}
里面的第一個調(diào)用pipelineOwner.flushLayout()就是本篇文章要講的布局階段了。好了,我們就從這里出發(fā)吧。先來看看PiplineOwner.flushLayout()。
void flushLayout() {
while (_nodesNeedingLayout.isNotEmpty) {
final List dirtyNodes = _nodesNeedingLayout;
_nodesNeedingLayout = [];
for (RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => a.depth - b.depth)) {
if (node._needsLayout && node.owner == this)
node._layoutWithoutResize();
}
}
}
這里會遍歷dirtyNodes數(shù)組。這個數(shù)組里放置的是需要重新做布局的RenderObject。遍歷之前會對dirtyNodes數(shù)組按照其在render tree中的深度做個排序。這里的排序和我們在構(gòu)建(build)階段遇到的對element tree的排序一樣。排序以后會優(yōu)先處理上層節(jié)點。因為布局的時候會遞歸處理子節(jié)點,這樣如果先處理上層節(jié)點的話,就避免了后續(xù)重復(fù)布局下層節(jié)點。之后就會調(diào)用RenderObject._layoutWithoutResize()來讓節(jié)點自己做布局了。
void _layoutWithoutResize() {
try {
performLayout();
markNeedsSemanticsUpdate();
} catch (e, stack) {
...
}
_needsLayout = false;
markNeedsPaint();
}
在RenderObject中,函數(shù)performLayout()需要其子類自行實現(xiàn)。因為有各種各樣的布局,就需要子類個性化的實現(xiàn)自己的布局邏輯。在布局完成以后,會將自身的_needsLayout標(biāo)志置為false。回頭看一下上一個函數(shù),在循環(huán)體里,只有_needsLayout是true的情況下才會調(diào)用_layoutWithoutResize()。我們知道在Flutter中布局,渲染都是由RenderObject完成的。大部分頁面元素使用的是盒子約束。RenderObject有個子類RenderBox就是處理這種布局方式的。而Flutter中大部分Widget最終是由RenderBox子類實現(xiàn)最終渲染的。源代碼中的注釋里有一句對RenderBox的定義
A render object in a 2D Cartesian coordinate system.
翻譯過來就是一個在二維笛卡爾坐標(biāo)系中的render object。每個盒子(box)都有個size屬性。包含高度和寬度。每個盒子都有自己的坐標(biāo)系,左上角為坐標(biāo)為(0,0)。右下角坐標(biāo)為(width, height)。
abstract class RenderBox extends RenderObject {
...
Size _size;
...
}
我們在寫Flutter app的時候設(shè)定組件大小尺寸的時候都是在創(chuàng)建Widget的時候把尺寸或者類似居中等這樣的配置傳進(jìn)去。例如以下這個Widget我們規(guī)定了它的大小是100x100;
Container(width: 100, height: 100);
因為布局是在RenderObject里完成的,這里更具體的說應(yīng)該是RenderBox。那么這個100x100的尺寸是如何傳遞到RenderBox的呢?RenderBox又是如何做布局的呢? Container是個StatelessWidget。它本身不會對應(yīng)任何RenderObject。根據(jù)構(gòu)造時傳入的參數(shù),Container最終會返回由Align,Padding,ConstrainedBox等組合而成的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 ");null ");null),
constraints =
(width != null || height != null)
");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對應(yīng)的會創(chuàng)建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()的實現(xiàn)。當(dāng)有孩子節(jié)點的時候,這里會調(diào)用child.layout()請求孩子節(jié)點做布局。調(diào)用時要傳入對孩子節(jié)點的約束constraints。這里會把100x100的約束傳入。在孩子節(jié)點布局完成以后把自己的尺寸設(shè)置為孩子節(jié)點的尺寸。沒有孩子節(jié)點的時候就把約束轉(zhuǎn)換為尺寸設(shè)置給自己。
我們看一下child.layout()。這個函數(shù)在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();
}
這個函數(shù)比較長一些,也比較關(guān)鍵。首先做的事情是確定relayoutBoundary。這里面有幾個條件:
parentUsesSize:父組件是否需要子組件的尺寸,這是調(diào)用時候的入?yún)ⅲJ(rèn)為false。
sizedByParent:這是個RenderObject的屬性,表示當(dāng)前RenderObject的布局是否只受父RenderObject給與的約束影響。默認(rèn)為false。子類如果需要的話可以返回true。比如RenderErrorBox。當(dāng)我們的Flutter app出錯的話,屏幕上顯示出來的紅底黃字的界面就是由它來渲染的。
constraints.isTight:代表約束是否是嚴(yán)格約束。也就是說是否只允許一個尺寸。
最后一個條件是父親節(jié)點是否是RenderObject。 在以上條件任一個滿足時,relayoutBoundary就是自己,否則取父節(jié)點的relayoutBoundary。
接下來是另一個判斷,如果當(dāng)前節(jié)點不需要做重新布局,約束也沒有變化,relayoutBoundary也沒有變化就直接返回了。也就是說從這個節(jié)點開始,包括其下的子節(jié)點都不需要做重新布局了。這樣就會有性能上的提升。
然后是另一個判斷,如果sizedByParent為true,會調(diào)用performResize()。這個函數(shù)會僅僅根據(jù)約束來計算當(dāng)前RenderObject的尺寸。當(dāng)這個函數(shù)被調(diào)用以后,通常接下來的performLayout()函數(shù)里不能再更改尺寸了。
performLayout()是大部分節(jié)點做布局的地方了。不同的RenderObject會有不同的實現(xiàn)。
最后標(biāo)記當(dāng)前節(jié)點需要被重繪。布局過程就是這樣遞歸進(jìn)行的。從上往下一層層的疊加不同的約束,子節(jié)點根據(jù)約束來計算自己的尺寸,需要的話,父節(jié)點會在子節(jié)點布局完成以后拿到子節(jié)點的尺寸來做進(jìn)一步處理。也就是我們開頭說的一下一上。
調(diào)用layout()的時候我們需要傳入約束,那么我們就來看一下這個約束是怎么回事:
abstract class Constraints {
bool get isTight;
bool get isNormalized;
}
這是個抽象類,僅有兩個getter。isTight就是我們之前說的嚴(yán)格約束。因為Flutter中主要是盒子約束。所以我們來看一下Constraints的子類:BoxConstraints
BoxConstraintsclass 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個屬性的不同組合構(gòu)成了不同的約束。
當(dāng)在某一個軸方向上最大約束和最小約束是相同的,那么這個軸方向被認(rèn)為是嚴(yán)格約束(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 ");0.0,
maxWidth = width != null ");double.infinity,
minHeight = height != null ");0.0,
maxHeight = height != null ");double.infinity;
BoxConstraints tighten({ double width, double height }) {
return BoxConstraints(minWidth: width == null ");null ");null ");null ");
當(dāng)在某一個軸方向上最小約束是0.0,那么這個軸方向被認(rèn)為是寬松約束(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,
);
}
當(dāng)某一軸方向上的最大約束的值小于double.infinity時,這個軸方向的約束是有限制的。
bool get hasBoundedWidth => maxWidth < double.infinity;
bool get hasBoundedHeight => maxHeight < double.infinity;
當(dāng)某一軸方向上的最大約束的值等于double.infinity時,這個軸方向的約束是無限制的。如果最大最小約束都是double.infinity,這個軸方向的約束是擴(kuò)展的(exbanding)。
const BoxConstraints.expand({
double width,
double height,
}) : minWidth = width != null ");double.infinity,
maxWidth = width != null ");double.infinity,
minHeight = height != null ");double.infinity,
maxHeight = height != null ");double.infinity;
最后,在布局的時候節(jié)點需要把約束轉(zhuǎn)換為尺寸。這里得到的尺寸被認(rèn)為是滿足約束的。
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的根節(jié)點是RenderView。在RendererBinding創(chuàng)建RenderView的時候會傳入一個ViewConfiguration類型的配置參數(shù):
void initRenderView() {
assert(renderView == null);
renderView = RenderView(configuration: createViewConfiguration(), window: window);
renderView.scheduleInitialFrame();
}
ViewConfiguration定義如下,包含一個尺寸屬性和一個設(shè)備像素比例屬性:
@immutable
class ViewConfiguration {
const ViewConfiguration({
this.size = Size.zero,
this.devicePixelRatio = 1.0,
});
final Size size;
final double devicePixelRatio;
}
ViewConfiguration實例由函數(shù)createViewConfiguration()創(chuàng)建:
ViewConfiguration createViewConfiguration() {
final double devicePixelRatio = window.devicePixelRatio;
return ViewConfiguration(
size: window.physicalSize / devicePixelRatio,
devicePixelRatio: devicePixelRatio,
);
}
可見,尺寸取的是窗口的物理像素大小再除以設(shè)備像素比例。在Nexus5上,全屏窗口的物理像素大小(window.physicalSize)是1080x1776。設(shè)備像素比例(window.devicePixelRatio)是3.0。最終ViewConfiguration的size屬性為360x592。
那么我們來看一下RenderView如何做布局:
@override
void performLayout() {
_size = configuration.size;
if (child != null)
child.layout(BoxConstraints.tight(_size));
}
根節(jié)點根據(jù)配置的尺寸生成了一個嚴(yán)格的盒子約束,以Nexus5為例的話,這個約束就是最大寬度和最小寬度都是360,最大高度和最小高度都是592。在調(diào)用子節(jié)點的layout()的時候傳入這個嚴(yán)格約束。
假如我們想在屏幕居中位置顯示一個100x100的矩形,代碼如下:
runApp(Center(child: Container(width: 100, height: 100, color: Color(0xFFFF9000),)));
運行以后則render tree結(jié)構(gòu)如下:
RenderView的子節(jié)點是個RenderPositionedBox。其布局函數(shù)如下:
@override
void performLayout() {
if (child != null) {
child.layout(constraints.loosen(), parentUsesSize: true);
size = constraints.constrain(Size(shrinkWrapWidth ");1.0) : double.infinity,
shrinkWrapHeight ");1.0) : double.infinity));
alignChild();
}
}
這里的constraints來自根節(jié)點RenderView。我們之前分析過,這是一個360x592的嚴(yán)格約束。在調(diào)用孩子節(jié)點的layout()時候會給孩子節(jié)點一個新的約束,這個約束是把自己的嚴(yán)格約束寬松以后的新約束,也就是說,給子節(jié)點的約束是[0-360]x[0-592]。并且設(shè)置了parentUsesSize為true。
接下來就是子節(jié)點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);
}
}
這里又會調(diào)用子節(jié)點RenderDecoratedBox的布局函數(shù),給子節(jié)點的約束是啥樣的呢? _additionalConstraints來自我們給我們在Container中設(shè)置的100x100大小。從前述分析可知,這是個嚴(yán)格約束。而父節(jié)點給過來的是[0-360]x[0-592]。通過調(diào)用enforce()函數(shù)生成新的約束:
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的嚴(yán)格約束了。最后我們就來到了葉子節(jié)點(RenderDecoratedBox)的布局了:
@override
void performLayout() {
if (child != null) {
child.layout(constraints, parentUsesSize: true);
size = child.size;
} else {
performResize();
}
}
因為是葉子節(jié)點,它沒有孩子,所以走的是else分支,調(diào)用了performResize():
@override
void performResize() {
size = constraints.smallest;
}
沒有孩子的時候默認(rèn)布局就是使自己在當(dāng)前約束下盡可能的小。所以這里得到的尺寸就是100x100;
至此布局流程的“一下”這個過程就完成了。可見,這個過程就是父節(jié)點根據(jù)自己的配置生成給子節(jié)點的約束,然后讓子節(jié)點根據(jù)父節(jié)點的約束去做布局。
“一下”做完了,那么就該“一上”了。 回到葉子節(jié)點的父節(jié)點RenderConstrainedBox:
child.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
size = child.size;
沒干啥,把孩子的尺寸設(shè)成自己的尺寸,孩子多大我就多大。再往上,就到了RenderPositionedBox:
child.layout(constraints.loosen(), parentUsesSize: true);
size = constraints.constrain(Size(shrinkWrapWidth ");1.0) : double.infinity,
shrinkWrapHeight ");1.0) : double.infinity));
alignChild();
這里shrinkWrapWidth和shrinkWrapHeight都是false。而約束是360x592的嚴(yán)格約束,所以最后得到的尺寸就是360x592了。而孩子節(jié)點是100x100,那就需要知道把孩子節(jié)點放在自己內(nèi)部的什么位置了,所以要調(diào)用alignChild()
void alignChild() {
_resolve();
final BoxParentData childParentData = child.parentData;
childParentData.offset = _resolvedAlignment.alongOffset(size - child.size);
}
孩子節(jié)點在父節(jié)點內(nèi)部的對齊方式由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);
其內(nèi)部包含兩個浮點型的系數(shù)。通過這兩個系數(shù)的組合就可以定義出我們通用的一些對齊方式,比如左上角是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)調(diào)用了。
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);
}
入?yún)⒕褪歉腹?jié)點的尺寸減去子節(jié)點的尺寸,也就是父節(jié)點空余的空間。分別取空余長寬然后除以2得到中值。然后每個中值在加上Alignment的系數(shù)乘以這個中值就得到了偏移量。是不是很巧妙?我們的例子是垂直水平都居中,x和y都是0。所以可得偏移量就是[130,246]。
回到alignChild(),在取得偏移量之后,父節(jié)點會通過設(shè)置childParentData.offset把這個偏移量保存在孩子節(jié)點那里。這個偏移量在后續(xù)的繪制流程中會被用到。
最后就回到了根節(jié)點RenderView。至此布局流程的“一上”也完成了。可見這個后半段流程父節(jié)點有可能根據(jù)子節(jié)點的尺寸來決定自己的尺寸,同時也有可能要根據(jù)子節(jié)點的尺寸和自己的尺寸來決定子節(jié)點在其內(nèi)部的位置。
總結(jié)本篇文章介紹了Flutter渲染流水線的布局(layout)階段,布局(layout)階段主要就是要掌握住“一下一上”過程,一下就是約束層層向下傳遞,一上就是尺寸層層向上傳遞。本篇并沒有過多介紹各種布局的細(xì)節(jié),大家只要掌握了布局的流程,具體哪種布局是如何實現(xiàn)的只需要查閱對應(yīng)RenderObject的源碼就可以了。
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://specialneedsforspecialkids.com/yun/6974.html
摘要:月日,谷歌正式發(fā)布了的。到底能不能成為跨平臺開發(fā)終極之選是基于前端誕生的,但是對前端開發(fā)來說,的環(huán)境配置很麻煩,需要原生的平臺知識,還要擔(dān)心遇上網(wǎng)絡(luò)問題。現(xiàn)在已經(jīng)不是曾經(jīng)的小眾框架,這兩年里它已經(jīng)逐步成長為主流的跨平臺開發(fā)框架之一。 ...
摘要:在本文中,我們將帶大家進(jìn)一步了解的搭建與運行。操作系統(tǒng)或更高版本磁盤空間工具依賴或更新的版本和命令行工具這些命令行工具。運行應(yīng)用程序定位到工具欄在中選擇一個運行該應(yīng)用的設(shè)備。 作者:個推iOS開發(fā)工程師 伊澤瑞爾 Flutter是Google推出的跨平臺的解決方案,用以幫助開發(fā)者在 Android 和 iOS 兩個平臺開發(fā)高質(zhì)量原生應(yīng)用的全新移動 UI 框架。 之前我們?yōu)榇蠹医榻B了《跨...
摘要:在本文中,我們將帶大家進(jìn)一步了解的搭建與運行。操作系統(tǒng)或更高版本磁盤空間工具依賴或更新的版本和命令行工具這些命令行工具。運行應(yīng)用程序定位到工具欄在中選擇一個運行該應(yīng)用的設(shè)備。作者:個推iOS開發(fā)工程師 伊澤瑞爾Flutter是Google推出的跨平臺的解決方案,用以幫助開發(fā)者在 Android 和 iOS 兩個平臺開發(fā)高質(zhì)量原生應(yīng)用的全新移動 UI 框架。 之前我們?yōu)榇蠹医榻B了《跨平臺框架F...
閱讀 3650·2021-09-22 15:15
閱讀 3555·2021-08-12 13:24
閱讀 1309·2019-08-30 15:53
閱讀 1816·2019-08-30 15:43
閱讀 1179·2019-08-29 17:04
閱讀 2792·2019-08-29 15:08
閱讀 1573·2019-08-29 13:13
閱讀 3084·2019-08-29 11:06