注册

Java、Kotlin不香吗?为什么Flutter要选用Dart作为开发语言?




以上片段改编自成龙大哥经典的洗发水广告,虽然梗本身有点过时了,但却很形象地反映了我对Dart语言态度的转变:从最初的排斥到最后的喜欢。


对于任何想要了解一门新兴技术的开发者来说,语言常常是横亘在学习之路上的第一道障碍,如C/C++之于音视频,Python之于人工智能等,当然也包括Dart之于Flutter。


尤其当你原先从事的是Android开发时,你肯定也曾产生过这样的疑惑:



既然同可以归到移动开发的范畴,也同属于Google旗下的团队,为什么Flutter不能沿用既有的Java或Kotlin语言来进行开发呢?



通过阅读本文,你的疑惑将得到充分的解答,你不仅能够了解到Flutter团队在选用Dart作为开发语言时的考量,还能充分感受到使用Dart语言进行开发的魅力所在。


照例,先奉上思维导图一张,方便复习:





热重载 (Hot Reload)一直以来都是Flutter对外推广的一大卖点,这是因为,相对于现有的基于原生平台的移动开发流程来讲,热重载在开发效率上确实是一个质的飞跃。



简单讲,热重载允许你在无需重启App的情况下,快速地构建页面、添加功能或修复错误。这个功能很大程度上依赖于Dart语言的一个很突出的特性:


同时支持AOT编译与JIT编译


AOT编译与JIT编译


AOT Compilation(Ahead-of-Time Compilation, 提前编译)是指在程序执行之前,将源代码或中间代码(如Java字节码)转换为可执行的机器码的过程。这么做可以提高程序的执行效率,但也需要更长的编译时间。


JIT Compilation(Just-in-Time Compilation, 即时编译)是指在程序执行期间,将源代码或中间代码转换为可执行的机器码的过程。这么做可以提高程序的灵活性和开发效率,但也会带来一些额外的开销,例如会对程序的初始执行造成一定的延迟。


用比较贴近生活的例子来解释二者之间的区别,就是:



AOT编译就像你在上台演讲之前,把原本全是英文的演讲稿提前翻译成中文,并写在纸上,这样当你上台之后,就可以直接照着译文念出来,而不需要再在现场翻译,演讲过程能更为流畅,但就是要在前期花费更多的时间和精力来准备。




JIT编译就像你在上台演讲之前,不需要做过多的准备,等到上台之后,再在现场将演讲稿上的英文逐句翻译成中文,也可以根据实际情况灵活地调整演讲内容,但就是会增加演讲的难度,遇到语法复杂的句子可能也会有更多的停顿。



可以看到,两种编译方式的应用场景不同,各有优劣,而Dart是为数不多的同时支持这两种编译方式的主流编程语言之一。根据当前所处项目阶段的不同,Dart提供了两种不同的构建模式:开发模式与生产模式。


开发模式与发布模式


在开发模式下,会利用 Dart VM 的 JIT 编译器,在运行时将内核文件转换为机器码,以实现热重载等功能,缩短开发周期。


热重载的流程,可以简单概括为以下几步:




  1. 扫描改动:当我们保存编辑内容或点击热重载按钮时,主机会扫描自上次编译以来的任何有代码改动的文件。




  2. 增量编译:将有代码改动的文件增量编译为内核文件。




  3. 推送更新:将内核文件注入到正在运行的 Dart VM。




  4. 代码合并:使用新的字段和函数更新类。




  5. Widget重建:应用的状态会被保留,并重建 widget 树,以便快速查看更改效果。




而在发布模式下,则会利用 Dart VM 的 AOT 编译器,在运行前将源代码直接转换为机器码,以实现程序的快速启动和更流畅地运行。


这里的“更流畅地运行”指的是在运行时能够更快地响应用户的操作,提供更流畅的用户体验,而不是单指让程序运行得更“快”。


这是因为Dart代码在被转换为机器码后,是可以直接在硬件上运行的,而不需要在运行时进行解释或编译,因此可以减少运行时的开销,提高程序的执行效率。


此外,经 AOT 编译后的代码,会强制执行健全的 Dart 类型系统,并使用快速对象分配和分代垃圾收集器来更好地管理内存。


因此,根据当前所处项目阶段的不同,采用不同的构建模式,Dart语言可以实现两全其美的效果


单线程模型


现如今,几乎所有的智能终端设备都支持多核CPU,为使应用在设备上能有更好的表现,我们常常会启动多个共享内存的线程,来并发执行多个任务。


大多数支持并发运行线程的计算机语言,如我们熟知的Java、Objective-C等,都采用了“抢占”的方式在线程之间进行切换,每个线程都被分配了一个时间片以执行任务,一旦超过了分配的时间,操作系统就会中断当前正在执行的线程,将CPU分配给正在等待队列的下一个线程。


但是,如果是在更新线程共享资源(如内存)期间发生的抢占行为,则可能会引致竞态条件的产生。竞态条件会导致严重的错误,轻则数据丢失,重则应用崩溃,且难以被定位和修复。


修复竞争条件的典型做法就是加锁,但锁本身会导致卡顿,甚至引发死锁等更严重的问题。


那Dart语言又是怎么解决这个问题的呢?


Dart语言采用了名为Isolate的单线程模型,Isolate模型是以操作系统提供的进程和线程等更为底层的原语进行设计的,所以你会发现它既有进程的特征(如:不共享内存),又有线程的特征(如:可处理异步任务)。


正如Isolate这个单词的原意“隔离”一样,在一个Dart应用中,所有的Dart代码都在Isolate内运行,每个Isolate都会有自己的堆内存,从而确保Isolate之间相互隔离,无法互相访问状态。在需要进行通信的场景里,Isolate会使用消息机制。


因为不共享内存,意味着它根本不允许抢占,因此也就无须担心线程的管理以及后台线程的创建等问题。


在一般场景下,我们甚至完全无需关心Isolate,通常一个Dart应用会在主Isolate下执行完所有代码。


虽然是单线程模型,但这并不意味着我们需要以阻塞UI的方式来运行代码,相反,Dart语言提供了包括 async/await 在内的一系列异步工具,可以帮助我们处理大部分的异步任务。关于 async/await 我们后面会有一篇单独的文章讲到,这里先不展开,只需要知道它跟Kotlin的协程有点像就可以了。



如图所示,Dart代码会在readAsString()方法执行非Dart代码时暂停,并在 readAsString()方法返回值后继续执行。


Isolate内部会运行一个消息循环,按照先进先出的模式处理重绘、点击等事件,可以与Android主线程的Looper相对照。



如图所示,在main()方法执行完毕后,事件队列会依次处理每一个事件。


而如果某个同步执行的操作花费了过长的处理时间,可能会导致应用看起来像是失去了响应。



如图所示,由于某个点击事件的同步处理耗时过长,导致其超过了处理两次重绘事件的期望时间间隔,直观的呈现就是界面卡顿。


因此,当我们需要执行消耗CPU的计算密集型工作时,可以将其转移到另外一个Isolate上以避免阻塞事件循环,这样的Isolate我们称之为后台运行对象



如图所示,生成的这个Isolate会执行耗时的计算任务,在结束后退出,并把结果返回。


由于这个Isolate持有自己的内存空间,与主Isolate互相隔离,因此即使阻塞也不会对其他Isolate造成影响。


快速对象分配与分代垃圾回收


在Android中,视图 (View)是构成用户界面的基础块,表示用户可以看到并与之交互的内容。在Flutter中,与之大致对应的概念则是Widget。Widget也是通过多个对象的嵌套组合,来形成一个层次结构关系,共同构建成一棵完整的Widget树。


但两者也不能完全等同。首先,Widget并非视图本身,最终的UI树是由一个个称之为Element的节点构成的;其次,Widget也不会直接绘制任何内容,最终的绘制工作是交由RenderObject完成的。Widget只是一个不可变的临时对象,用于描述在当前状态下视图应该呈现的样子


而所谓的Widget树只是我们描述组件嵌套关系的一种说法,是一种虚拟的结构。但 Element和RenderObject是在运行时实际存在的,如图:



这就好比手机与其规格参数的关系。Widget就像是一台手机的规格参数,是对当前组装成这个手机的真正的硬件配置的描述,当手机的硬件有更新或升级时,重新生成的规格参数也会有所变化。


由于Widget是不可变的,因此,我们无法直接对其更新,而是要通过操作状态来实现。但实际上,当Widget所依赖的状态发生改变时,Flutter框架就会重新创建一棵基于当前最新状态绘制的新的Widget树,对于原先的Widget来说它的生命周期其实已经结束了。


有人可能会对这种抛弃了整棵Widget树并完全重建一棵的做法存有疑问,担心这种行为会导致Flutter频繁创建和销毁大量短暂的Widget对象,给垃圾回收带来了巨大压力,特别对于一些可能由数千个Widget组合而成的复杂页面而言。


实际上这种担心完全没有必要,Dart的快速对象分配与分代垃圾回收足以让它应对这种情况。


快速对象分配


Dart以指针碰撞(Bump Pointer)的形式来完成对象的内存分配。


指针碰撞是指在堆内存中,Dart VM使用一个指针来跟踪下一个可用的内存位置。当需要分配新的内存时,Dart VM会将指针向前移动所需内存大小的距离,从而分配出新的内存空间



这种方式可以快速地分配内存,而不需要查找可用的内存段,并且使内存增长始终保持线性


另外,前面我们提到,由于每个Isolate都有自己的堆内存,彼此隔离,无法互相访问状态,因此可以实现无锁的快速分配。


分代垃圾回收


Dart的垃圾回收器是分代的,主要分为新生代(New Generation)与老年代(Old Generation)。


新生代用于分配生命周期较短的临时对象。其所在的内存空间会被分为两半,一个处于活跃状态,另一个处于非活跃状态,并且任何时候都只使用其中的一半。



新的对象会被分配到活跃的那一半,一旦被填满,垃圾回收器就会从根对象开始,查找所有对象的引用状态。




被引用到的对象会被标记为存活状态,并从活跃的一半复制到非活跃的一半。而没有被引用到的对象会被标记为死亡状态,并在随后的垃圾回收事件中被清除。



最后,这两半内存空间会交换活跃状态,非活跃的一半会再次变成活跃的一半,并且继续重复以上过程。



当对象达到一定的生命周期后,它们会被提升为老年代。此时的垃圾回收策略会分为两个阶段:标记与清除。


首先,在标记阶段,会遍历整个对象图,标记仍在使用的对象。


随后,在清除阶段,会扫描整个内存,回收任何没有被标记的对象,然后清除所有标记。


这种形式的垃圾回收发生频率不高,但有时需要暂停Dart Runtime以支持其运行。


为了最小化地降低垃圾回收事件对于应用程序的影响,垃圾回收器为Flutter引擎提供了钩子,当引擎检测到应用程序处于空闲状态并且没有用户交互时会发出通知,使得垃圾回收器可以在不影响性能的情况下执行回收工作。


另外,同样由于每个Isolate都在自己都独立线程内运行,因此每个Isolate的垃圾回收事件不会影响到其他Isolate的性能。


综上可知,Flutter框架所采用的工作流程,很大程度上依赖于其下层的内存分配器和垃圾回收器对于小型的、短生命周期的对象高效的内存分配和回收,缺少这个机制的语言是无法有效运作的


学习成本低


对于想要转岗Flutter的Android或iOS开发者,Dart语言是很友好的,其语法与Kotlin、Swift等语言都存在一些相似之处。


例如,它们都是面向对象的语言,都支持类、接口、继承、抽象类等概念。绝大多数开发者都拥有面向对象开发的经验,因此可以以极低的学习成本学习Dart语言。


此外,Dart语言也拥有着许多与其他语言相似的优秀的语法特性,可以提高开发人员的生产力,例如:




  • 字符串插值:可以直接在字符串中嵌入变量或表达式,而不需要使用+号相连:
    var name = 'Bob'; print('Hello, $name!');




  • 初始化形式参数:可以在构造函数中直接初始化类的属性,而不需要在函数体中赋值:
    class Point { num x, y; Point(this.x, this.y); }




  • 函数式编程风格:可以利用高阶函数、匿名函数、箭头函数等特性简化代码的结构和逻辑:
    var numbers = [1, 2, 3]; var doubled = numbers.map((n) => n * 2);




Dart团队配合度高


拥有一定工作年限的Android开发者,对于早些年Oracle与Google两家科技公司的Java API版权之争可能还有些许印象。


简单讲就是,Oracle认为Google在Android系统中对Java API的复制使用侵犯了其版权和专利权,这场持续了11年的专利纠纷最终以Google的胜利结束。


相比之下,Dart语言与Flutter之间则没有那么多狗血撕逼的剧情,相反,Flutter与Dart社区展开了密切合作,Dart社区积极投入资源改进Dart语言,以便在Flutter中更易使用。


例如,Flutter在最开始采用Dart语言时,还没有用于生成原生二进制文件的AOT工具链,但在Dart团队为Flutter构建了这些工具后,这个缺失已经不复存在了。


结语


以上,就是我汇总Flutter官网资料及Flutter社区推荐博文的说法之后,总结出的Flutter选用Dart作为开发语言的几大主要原因,希望对于刚入门或想要初步了解Flutter开发的小伙伴们有所帮助。


作者:星际码仔
链接:https://juejin.cn/post/7225629551602565178
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册