flutter学习笔记

共 13062字,需浏览 27分钟

 ·

2026-06-16 13:12

零:Flutter重要的界面设计哲学

组合大于继承,即把宽高、背景、边框等众多的属性进行了分离,把它们变成了部件,然后对它们进行嵌套组合,以实现我们想要的界面效果。

比如你要既指定宽高,又要背景,又要边框,那就得把三个部件甚至更多进行嵌套,如:


宽高(

  背景(

    边框(

      内容

    )

  )

)


这个初一看,实在不舒服,那它为什么要这样设计呢?原因:

1、为了性能:每个部件都有很多属性,创建对象时是比较占内存的一件事;

2、为了简单:每个部件只做一件事情,职责单一,比较好理解,就好维护。


但是结果是嵌套层级太深了,渲染对象就多了,代码书写量也大,我觉得这一点很不好,这是一种物极必反的做法,我的建议是:

把常用的那些属性就指定给每个部件,比如宽高、背景、边框等,其它不常用的再按原来的嵌套法,即取个平衡点。这样结果是:

代码量与嵌套层级会一下少很多,而性能上还又不损失,因为它处省的时间空间又用于那些最常用的属性了,但总体是利大于弊。


一、关于Widget、Element、RenderObject

(1) widget:界面模板中的小部件;

(2) element:通过界面模板生成的元素;

(3) renderObject:真实的渲染对象,它才是真正显示到界面上的可视化内容;


我们思考一下为什么要这么设计呢?

首先要理解,flutter的界面是数据驱动的,就像vue等框架那样,数据改了之后,会通过界面HTML去重新生成界面,而不是传统的那样,我们直接操作

DOM。那widget就相当于vue模板中的Component,这个Component只是生成DOM的模板蓝图。

renderObject呢?它就相当于DOM元素,是直接参与显示的东西。

那么element呢?似乎它是多余的?vue不就是直接通过vue模板生成了DOM吗?其实不然,vue的中间还有一个虚拟DOM的概念。当更新了数据时,vue

会通过模板去生成一个新的虚拟DOM,然后用这个新的虚拟DOM与旧的虚拟DOM进行比对,找出修改的差异部分,然后通过这个差异去修改真实的DOM。

那它为什么要这么做呢?二个字:效率。

那么到这相必应该理解了吧?element相当于vue的虚拟DOM中的元素,如果把widget比作类,那么element就是类实例。

flutter的UI机理跟vue有些类似,大致一个思想,所以只要以vue的思想去理解flutter就好理解了。但是也得注意这只是类似,实际上是很不一样的。


简单总结就是:

(1) widget:相当于vue模板中的组件,主要起了一个UI配置描述作用,可简记为元素描述;

(2) element:相当于vue虚拟DOM中的元素,用于比对出需修改的差异,可简记为虚拟元素;

(3) renderObject:相当于HTML元素,即页面真实DOM中的可视节点,可简记为真实元素;


后来有次突然想到了一个问题,实际上widget树与element树是一对一关系,每一个widget实例必然要对应一个element元素,那我就想了为什么不

能把两棵树合并到一起呢,从技术上来讲是没有问题的呀?后来知道了,主要是出于性能原因分开的,widget树是很轻量的,element树是重量的,用

轻量的树去比对重量的树要不要复用,才是最佳做法。


二、关于RenderObjectWidget类

(1) 但凡需要真正显示的Widget都继承于RenderObjectWidget类,有些Widget像StatelessWidget,虽然它的孩子可能需要真正显示,然它本身

    并不需要真正显示,它只相当于需真正显示的widget的组合,它本身是不占任何显示区域的,像Padding小部件就是一个典型的例子;

(2) MultiChildRenderObjectWidget就是继承于RenderObjectWidget,它可以包含多个孩子,像Row、Column小部件就是一个典型的例子;

(3) 同理还有只包含单个孩子的SingleChildRenderObjectWidget;

(4) RenderObjectWidget有一个createRenderObject()方法,用于创建真正的渲染对象,Element在装载渲染对象时,会自动调用createRenderObject(),

方法,而像StatelessWidget是没有这个方法的,这只有createElement()方法,因为前面已说过,它只是概念上的显示群组,不是屏幕上的显示对象;


三、关于RenderObjectElement、ComponentElement类

ComponentElement类的注释是:An [Element] that composes other [Element]s. 从注释可以看到,它用于创构其它元素。通过查看源码可

以看到它有一个_child字段,它的父级有一个_parent字段,Element树就是通过这二个字段关联起来的,在Widget层相当于StatefulWidget及StatelessWidget。

RenderObjectElement类的注释是:An [Element] that uses a [RenderObjectWidget] as its configuration.从注释可以看到,它是

RenderObjectWidget对应的Element。通过查看源码可以看到它有一个_renderObject字段,即它才是真正拥有RenderObject的Element。


四、关于RenderObjectWithChildMixin与ContainerRenderObjectMixin混合

RenderObjectWithChildMixin用于只有一个孩子的RenderObject,而ContainerRenderObjectMixin用于有多个孩子的RenderObject。


五、摘抄部分

渲染对象RenderObject是一个抽象类。我们需要继承它来完成自定义控件的渲染。它有两个重要的子类RenderBox和RenderSliver。这两个类分别

实现box协议(笛卡尔坐标系)和sliver协议(滚动视图坐标系),这两个类还被其他几十个类继承,这些子类分别处理特定的场景,并实现渲染过程的细节。

如果我们直接从RenderObject继承,就无法复用已有的布局协议,通常来说,应该从它的子类RenderBox类去派生自定义类。但是直接继承

RenderBox仍然会有些细节处理,较为繁琐,通常我们可以去继承RenderBox的两个子类RenderShiftedBox或RenderProxyBox。


六、调试分析

(1) 渲染树的根是RenderView,与它平级关联的是RenderObjectToWidgetAdapter与RenderObjectToWidgetElment;

(2) RenderObjectToWidgetAdaper的renderObject是由框架直接传递过去的,并不是createRenderObject方法创建的,同时它也包含了一个

孩子,即传递给runApp的Widget实例,RenderObjectToWidgetAdaper的作用就是用于关联RenderObject与Widget,这就是叫做RenderObjectToWidget

的原因,它的注释是:A bridge from a [RenderObject] to an [Element] tree.;

(3) RenderObject树的层级与Element树的层级是不一样的,因为并不是所有Element都对应有一个RenderObject;


七、摘抄部分

目前Flutter官方提供了Material design和cupertino两种风格的组件库,说实话,都不太符合国人审美,所以还是需要花费很多时间去

封装自己的组件的。这两种组件库都是基于基本控件库设计的,所以说基本控件是砖块,那两种组件库中的组件是建筑。顺便想到了比基本控件更基础的是UI样式,

如下划线样式等,像TextDecoration就是一个UI样式,与Material有关的放在materia目录下,基本控件放在widgets目录下,UI样式相关的放在ui目录下。


可以把material比作自定义设计的html组件库,widgets比作html中的基本元素,比如image、label等,ui样式就相当于css样式。

有一个painting目录下放着好多与绘制有关的东西,这个就如css样式集,属于装饰性的,widget是用于做骨架的,装饰要靠其它的。


八、思考一个问题

能否提供一个类似JSX的界面描述功能,然后把它编译成Flutter现在描述界面的代码,因为这是完全可行的方法。因为它可以大大提高界面开发

效率,其实它就是HTML,可以说HTML/CSS是当今世界上描述界面最好的方法了。当然如果有人不喜欢JSX的风格去描述界面,完全可以直接使用

现在的界面描述方法。

附注:我觉得flutter完全可以实现一套html/css编译器,直接把现有的html/css编译成直接构建界面的代码,省去了解析环节,也提高了开

发效率。现在的这套UI吗,说句真心话:复杂的不好用。而html/css,界面骨架与界面皮肤完全分离,而且元素的本质都一样,就是一个显示

节点,我们可以把相同的样式赋予每一个元素,大大减少了记忆量。比如a与div的默认样式不一样,然我们可以给它赋予同样的样式。


九、UI的通用渲染机制

这是一种适用于任何界面系统的方法,而且也是唯一一种高效且灵活的方法:

(1) 内部有一个时钟,1秒钟可以生成30-60(为啥是这些数字呢?因为1秒钟内连续画面达到30幅时人眼是无法识别出画面是一幅一幅切换的,

反而就像看实物在运动那样是连贯的,那为啥不用30呢?因为60看起来运动或变化更加细腻,所以说60才是人眼的极限)次界面渲染事件;

(2) 每当渲染事件到来时,我们就重新计算一下元素的大小及位置,并按一定顺序重绘元素,具体细节如下:

    <1> 计算出改变的元素根(flutter中叫重绘布局限定元素),没有改变的元素不需要重新计算,以提高其效率;

    <2> 从这个根开始从上往下以深度优先的方法去遍历每个元素,为啥这样呢?因为要先子后父的计算元素的大小(简称为度量阶段);

    <3> 父元素大小计算完成以后就可以对其子元素进行定位了,定位就是它应该绘制在相对其父元素的什么位置处(简称为布局阶段);

    <4> 当所有元素大小及位置都计算好以后,再从最根部从上往下且以深度优先的方法调用其每个元素的绘制方法(简称为渲染阶段);

以上这些概念在几乎所有UI系统中都有它的身影,尽管细节上有点差异,但大致过程都是一样的,都有度量、布局、渲染类似字眼的流程。

结合实际:

在flutter中,在<2>阶段,父元素会向子元素传递一个约束,即限定最小、最大宽高的一个限定信息,子元素在计算大小时要考虑到它。

在flutter中,<2>与<3>阶段合称为布局阶段,它由layout()、performLayout()、performResize()方法来完成,子类不应该覆

盖layout()方法,代替应该覆盖performLayout()与performResize()方法,layout()方法会把真实的工作委派给它们俩去执行。

还有performLayout()方法应无条件的调用其孩子的layout()方法。

而<4>阶段称为绘制阶段,它由paint()方法来完成,绘制即在canvas上进行绘画。最后flutter会把这此canvas上的画处理以后显示

出来。

附加备注:

为了提升性能,<4>阶段可以使用多个图层,这样就不会出现一个元素改变了就要重绘所有元素这样低效的事情。然而图层多了最终合成

也是问题。所以通常是多个元素共用一个图层,即采用了一种折中的方法。通常重布局根元素及其子树的绘制会单独开一个图层,最后再合成图层。

在flutter中就有图层的概念,图层通常用Layer这个词表示。


十、Flutter的UI与HTML对比

(1) Flutter的每个UI组件都只肩负起一个相对比较简单的职责,这样需要多个功能时就进行组合,为什么这样设计呢?可能是为了效率吧。然

这个方法也会导致UI组件树层级会变得非常深,又反过来折损了性能(可参考精简指令集的思想来理解);

后来在其它文章中看到的,我认为它是FlutterUI的核心设计哲学:

Flutter小部件是使用积极的(就是鼓励要像搭积木一样进行各种组合)可组合性构建的,所以使用Flutter构建的用户界面具有大量的小部件。

后来又看到一句话:

Widget设计遵循“组合大于继承”这一优秀的设计理念,通过将多个功能相对单一的Widget组合起来便可得到功能相对复杂的Widget。

(2) 而HTML中每一个元素都可以认为是一样的(除过几个像img、a标签那样内置了一些特殊功能的标签),因为它可以通过样式去改变外观,每一

个都可以触发或处理事件。HTML的方式描述UI非常方便,但是也因为每个元素都几乎把所有功能集于一身了,处理每一个元素的耗时就多了,这样

性能就会有所折损(可参考复杂指令集的思想来理解);


十一、关于HitTestBehavior枚举

(1) deferToChild:当孩子被击中时该元素才算被击中(击中即鼠标或手指在元素上进行了动作);

(2) opaque:该元素可以被直接击中,然它背后的元素不能被击中;

(3) translucent:该元素及其它背后的元素都可以被击中;


十二、RenderObjectElement.attachRenderObject()

用于把该元素的renderObject系到renderObject树上去。

方法是:先查找该元素之上的RenderObjectElement(是通过循环沿树逐层往上找)的renderObject(简记为PR),然后

直接把该元素的renderObject赋给PR的child属性即可。


十三、@pragma("vm:entry-point", "call")

但凡有这个声称的方法都是由dart虚拟机进行调用的方法。系统中有很多功能是通过c代码来实现的,当这些代码需要回调

dart代码时就会通过dart虚拟机来进行。


十四、Timer.run

它相当于js中的setTimeout(回调, 0),即尽快异步的执行回调函数。


十五、parentUsesSize与sizedByParent

(1) parentUsesSize:表示子节点布局变化是否影响父节点,如果为 true,那么子节点布局发生变化时,父节点就会标记为需要重新

布局,如果为false,则子节点布局发生变化后不影响父节点(简记为父亲的大小依赖于孩子大小);

(2) sizedByParent:表示该节点的大小是否通过 parent 传递给它的 constraints 就可以确定,即该节点的大小与自身的属性及

其子节点无关,比如,如果一个控件永远充满 parent 的大小,那么sizedByParent就为true,其大小由performResize确定(简记

为孩子大小只依赖于父亲传给孩子的约束)。当它为true时,不应该使用performLayout(),而是要用performResize()调整大小;


十六:源码片断分析

layout() 中:

以下用于确定需要重新布局的元素根,以这个元素为根的子树就要重新走一遍布局过程,以重新确定每个元素的大小与定位。

if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {

    relayoutBoundary = this;  当前元素作为重布局元素根。

} else {

    relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;  取其当前元素父亲的重布局元素根。

}


十七:关于Semantics

它的意为语义,即辅助描述信息,就像有些软件当中的供屏幕阅读器使用的信息一样,就是起了个辅助描述作用。

当Flutter渲染控件树时,它还会维护第二个控件树,称为Semantics Tree。

Semantics Tree的每个节点都是SemanticsNode,它可能对应于一个或一组Widget。

每个SemanticsNode都会对应一个SemanticsConfiguration,保存着语义属性信息。

这些可以简记为:相当于html中元素的title属性,给人以提示用的。


十八:关于parentData

这个属性用于父元素在子元素中存储数据。通常是一个BoxParentData类型的实例。其中有一项offset存储着子元素的绘制偏移。


十九:ParentDataWidget

用于为widget树中下面的(即子孙)与RenderObjectWidget关联的RenderObject设置parentData,具体的设置方法是applyParentData()。

至于这样做用在什么场景下,暂时也不知道。但是一定要知道,这种通过祖先widget向子孙widget传递数据的做法在flutter中用得还较多。

flutter会在需要的时候通过elelment树向上查找它所需要的widget,并取得里面的数据。

这种做法,我感觉效率比较低下吧?光这个向上查找就浪费了不少时间吧。然这个方法也许是为了实现积极的widget可组合性的最合适方法吧。

附注:widget树只存在于概念中,实际代码中并不存在,而Element、Render树确是存在的。


二十:InheritedWidget

它类似于ParentDataWidget,也是用于向子孙widget传递数据的。在子孙中可通过BuildContext.dependOnInheritedWidgetOfExactType

获取最近的InheritedWidget中提供的数据,这个数据可以是任何类型的数据。然需要注意的是通过这种方式获取Inherited Widget时,当它的

状态有变化时,会导致该引用方rebuild。为什么呢?因为引用方监听了Inherited Widget提供的数据。监听的作用就是用来监视数据的变化。

我们知道,flutter是数据驱动界面的框架,只要数据发生了改变,就得去重新渲染一下界面,所以必须得知道数据在何时发生了变化。


二一:BuildOwer

BuildOwer在Element树的管理上起到重要作用。BuildOwer实例由WidgetsBinding负责创建,并赋值给Element Tree的根节点

RenderObjectToWidgetElement,此后随着Element Tree的创建逐级传递给子节点,即整棵Element Tree共享同一个BuildOwer实例。

参考代码如:_owner = parent.owner 即把父亲的owner赋给了当前孩子,它存在于 Element.mount() 方法中。

BuildOwer两个关键成员变量:

final _InactiveElements _inactiveElements = _InactiveElements();

final List<Element> _dirtyElements = <Element>[];

其命名已清晰表达了他们的用途:分别用于存储收集到的Inactive Elements(不活动元素)、Dirty Elements(脏元素,即需刷新的元素)。

脏元素列表由Elment.markNeedsBuild()方法触发把脏元素添加进去。此后,在新一帧绘制到来时,WidgetsBinding.drawFrame会调用

BuildOwer.buildScope方法,而它就会从上往下(即先父后子)并深度优先的顺序遍历这个脏元素列表并调用每个脏元素的rebuild()方法。

不活动元素列表的主要目的是实现『带有「global key」的 element』可以带着『状态』在树上任意移动。如果本帧结束时这些元素未被用到将会

被处置,即调用元素状态的dispose()方法,而在把元素添加到不活动元素列表中时会调用元素状态的deactivate()方法。


二二:var与dynamic

var 用于类型推演,即由编译器根据初始值来确定类型,类型一旦确定将不可更改。

dynamic 用于动态或可变类型,即可以是任意类型,编译器并不关心它的类型是什么,只有在类型转换时运行时才去判断类型的正确性。


二三:Element.slot属性

Element.slot 其含意对子节点透明,父节点用于确定其下子节点的排列顺序 (兄弟节点间的排序)。因此,对于单子节点的节点

(single child),child.slot 通常为 null。另外,slot 的类型是动态的,不同类型的 Element 可能会使用不同类型的 slot。

它跟parentData是类似的,即它只服务于父亲,对孩子本身来说没用。不知为什么slot不合并到parentData中呢?这样岂不更加简单易理解。


二四:PaintingContext

通过名称可知它代表了底层的绘制上下文,用它可以进行真正的绘图操作。其中有个属性canvas,这个经常用到,它即画布。应用程序进行绘图主要

调用的是它上面的方法。还有一个属性recorder,用于记录其在canvas上进行的绘图操作。


二五:关于Layer

它即绘画图层,图层才是真正承载画图结果的东西,每一个canvas背后都有关联的图层,canvas与layer是多对一关系。

ContainerLayer代表着可包含其它图层的图层,可以理解为图层组。

多个图层最终可形成一个scene,它可以理解为最终图层组或根图层组,它会直接输出到底层去显示。


二六:PipelineOwner

在此我想到了,flutter中的owner也即其它系统中常叫的manager,那么PipelineOwner就可以叫做PipelineManager,这样就好理解了,即它

是渲染管线的管理器,那么只要与渲染管理有关的,如RenderObject树等,都会由它进行管理。


二七:external关键字

它的意为:相当于非抽象类中的抽象函数。用于该函数会有不同的实现。此种实现处通常会有一个注释@patch,即补丁。然这个关键字并不是给应用

开发人员用的,虽然用它在编译时也不报错,然程序是无法运行的。就相当于c/c++中声明引用的库函数一样,编译时没问题,但是它还要进行一个链

接。然dart并未提供给应用开发人员一个正常的方法去编写这个external函数的实现。


二八:native关键字

示例用法:

void _scheduleMicrotask(void Function() callback) native 'ScheduleMicrotask';

意为dart中的_scheduleMicrotask函数对应于c&c++中的ScheduleMicrotask函数。这个关键字用的还是比较多的,因为与底层打交道的时候

都需要用它来进行声明。


二九:const部件

const关键字修饰的部件实例在运行时总是不变,有利于提升性能。


三十:AnimatedOpacity

它实现动画的原理是:它是个StatefulWidget,它的状态对象内部使用了AnimationController,状态对象又构建了FadeTransition,

它是一个SingleChildRenderObjectWidget,它的createRenderObject方法使用了RenderAnimatedOpacity渲染对象,这个

对象在内部给Animation<double>动画实例添加了事件监听器,动画更新时它会在updateCompositedLayer方法中更新渲染对象的

图层透明度,这是一个独立图层,仅用于RenderAnimatedOpacity这个渲染对象,这样图层就会变得透明。

顺便提下:父亲用了新图层后,它下面的孩子默认就是用这个新图层,以便绘制在父亲上面,当然孩子也可以再用新的图层来绘制。图层

就是一个独立的绘制上下文,最后系统会把所有图层的像素合并到一起进行显示。这样的目的就是为了提升性能,因为不用重绘全部。只

要把RenderObject的isRepaintBoundary属性设计为true,就代表以这个对象为根的渲染树要使用新的图层进行绘制了。


三一:rebuild与performRebuild

rebuild会调用performRebuild,而performRebuild主做二件事:

1、调用widget的build()方法,创建出一个新widget,突然想到这样的设计好吗?是否StatelessWidth的build方法直接返回它的

child实例比较好呢,这样widget嵌套层级会大幅减少,后面值得思考下,然StatefulWidght还是原来的比较好;

2、调用element的updateChild()方法,更新element的child与上面新的widget进行关联,它又分为二种情况:

(1) child=null时:就用inflateWidget(widget)创建一个与widget的关联element,内部是调用widget.createElement()

实现的,完了执行element.mount(),它又会递归调用rebuild,直到它的子树全部都装载完成;

(2) child!=null时:即要把child更新成新的element,或者复用它,复用条件是新旧widget类型及key为相同。


三二:sizedByParent与performResize

当RenderObject.sizedByParent=true时,意为自身大小与它的孩子没关系,只与父亲传过来的尺寸约束有关系,此时layout方法

会调用performResize设置自身大小,不过建议以后重写computeDryLayout方法,它是performResize的替代版。

此时孩子们的_isRelayoutBoundary=true,即孩子们的布局改变了时,不会向上影响到父亲,所以叫重新布局边界。

注意:它与绘制层的绘制边界不一样,绘制边界是新开了一个图层,某个子树全部绘制到这个新图层上,不影响旧图层。


当RenderObject.sizedByParent=false时,意为自身大小与它的孩子有关系,只有自下而上把孩子们的尺寸计算好了之后,才能得

知自身的大小,80%左右的部件大小都依赖于孩子部件的大小。此时会通过child.layout(constraints, parentUsesSize: true)

去布局孩子,parentUsesSize=true意为我需要使用child的大小,child.layout应计算出孩子的大小。


三三:布局位置

RenderObject的定位是用Offset(dx, dy)来实现的,父亲在调用孩子的paint方法时会传递一个Offset实例,里面有x与y的偏移量,

像left, top, right, bottom的约束,最终是要转换成偏移量的,它决定了对象向绘制图层绘制时的坐标。


三四:BuildContext

它只是一个接口,实际的实例是Widget对应的Element,使用接口是为了阻止直接操作Element对象,可以参见BuildContext的注释。

用这个就可以自下而上扫描整个element树,当然同时也能扫描到widget树,因为每个element都会关联一个widget。


三五:PlatformInterface

为了让插件的平台实现继承于插件抽象规范,而不是实现插件抽象规范,实现会导致修改了插件抽象规范时插件的平台实现必须进行修改的

连锁反应问题,所以PlatformInterface提供了一个token属性,只要插件类都共享了这个一样的token属性就说明是继承而不是实现了。

举例:P是插件抽象规范,PA是android平台实现,PW是windows平台实现,如果PA与PB implements P,那么P增加了一个方法时,

PA与PB都得增加这个新方法的实现,所以不能用implements,而是要用extends,这样P中给新方法一个默认实现即可,PA与PB都不用改。

然怎么限定PA与PB是继承了P呢?答案是,校验PA与PB是否共享了P提供给PlatformInterface的token属性值,通过verify方法校验

PA与PB的token与P的是否一样即可,只要PA与PB继承了P,那一定是一样的,注意P是继承于PlatformInterface的。

把插件与具体平台关联的单独放一个包,再把无关的放一个包,无关的就像一个总包,与平台关联的就像一个分包,这种插件叫做联绑插件。

举例如:url_launcher插件

url_launcher:这里面放的是面向应用开发者的api,底层会调用url_launcher_platform_interface,它是需要添加到依赖中的;

url_launcher_platform_interface:这里面放的是面向要特定平台实现的抽象api,这些api不同平台有不同的实现;

url_launcher_android:这里面放的是android平台的具体实现,它实现了url_launcher_platform_interface;

url_launcher_windows:这里面放的是windowds平台的具体实现,它实现了url_launcher_platform_interface。

以上包用哪个是随构建目标而定的,当构建apk时,会自动使用url_launcher_android包,系统会自动把具体实现的实例通过抽象类的

instance属性设置给抽象类,这样面向应用开发者的类就能通过抽象类获取到这个实例了,从而就调用到了不同平台的实现。


三六:markNeedsLayout与markNeedsPaint

这两个在执行时,都是向上进行标记,直到重布局边界与重绘边界才停止。然后系统会把这两个边界对象添加到待处理队列中。待到处理时,

对于重布局边界对象会为其调用_layoutWithoutResize方法,而对于重绘边界对象会为其调用paint(绘制上下文,Offset.zero)。

重点是一个不计算尺寸,一个传的参数offset是zero,前者是因为系统假定尺寸不会变,后者是因为是在独立的图层上进行绘制,边界对

象就始终就是以原点即图层左上角进行绘制的,后续当这个边界对象需要调整偏移时,系统会把整个图层进行偏移来实现。

浏览 1
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报