0%

本文为转载。

作者:zerygao
链接:https://www.jianshu.com/p/5f16e1de6d5a
来源:简书

iOS无埋点数据 SDK 实践之路
iOS无埋点SDK 之 RN页面的数据收集

本篇文章是讲述 iOS 无埋点数据收集 SDK 系列的第三篇,之前的两篇文章都只是讲述了某一方面的内容,而本篇会详细介绍下 SDK 的整体设计以及各个模块的功能和实现思路。

SDK 的整体设计

先看一张 SDK 的整体设计图:

img

从上图看出,SDK 整体上主要包含 4 个部分:AOPEvent CollectorEvent CacheEvent Upload。其中,每个部分是一个相对独立的功能模块,同时模块之间通过图中的方式进行通信。

SDK 中的这 4 个模块各自的主要功能如下:

  • AOP:提供数据收集所需要的时机,即通过 Method Swizzlinghook 相应类的方法,然后以 Post Notification 的方式提供出去。
  • Event Collector:监听通知,针对当前事件执行相应的数据收集,并将收集的事件数据提交给缓存模块。
  • Event Cache:负责事件数据的缓存、序列化以及读取操作,其中包括内存缓存与磁盘缓存。
  • Event Upload:基于一定的上报策略执行对已收集的事件数据的上报。

接下来逐个介绍上述 4 个模块的具体实现细节。

AOP

这个模块的主要功能就是提供 SDK 执行数据收集所需的时机,在实现上又可以细分为 2 个方面:

  1. 实现 AOP 编程
  2. hook 类的方法

实现 AOP 编程

在 iOS 中实现 AOP 编程的技术就是基于 Objective-C Runtime 特性的 Method Swizzling。而在 Github 上已经有一个很不错的实现了 AOP 的开源库-Aspects,它的实现也是利用了 Objective-C 的消息转发机制与 Method Swizzling 黑魔法。

但是,SDK 最终并未使用 Aspects 库,虽然 Aspects 封装的很好而且很好用,但是它并不能完全满足项目的需要,主要表现在如下 2 个方面:

  1. Aspects 无法 hook 类中不存在的方法,或者未实现的方法。
  2. Aspects 不支持 hook 类的类方法。

因此,SDK 单独实现并封装了一个用于执行 hook 的类,其实现也是对 NSObject 的扩展,类似于 Aspects。

hook 的方法

上篇文章 中简单提了一下,SDK 在实现对基本事件数据的自动收集时,主要 hook 的方法分为 3 类:

  • 系统类的方法
  • 系统类的 Delegate 方法
  • 自定义类的方法

那么,接下来就详细的介绍一下,SDK 在实现对事件的收集时,具体 hook 了哪些类的哪些方法。

各类点击事件的拦截

对于 SDK 来说,收集用户的所有点击的行为数据是非常重要的一部分。另外,这部分数据对于用户行为分析以及统计路径转化率时,都是至关重要的。

那么 SDK 对于用户的各类点击事件的收集,主要 hook 了如下的一些系统类的方法:

img

针对上图,做一些简要的说明:

  1. 所有的 UIControl 类型的控件、UITabBarButton 以及在导航栏上自定义添加的 UIBarButtonItem 的点击事件,都可以通过 hook 系统类UIApplicationsendAction:to:from:forEvent: 方法进行拦截。但是,这个方法并不能拦截到导航栏上系统自动添加的返回按钮的点击,因此 SDK 又 hookUINavigationControllernavigationBar:shouldPopItem: 方法来实现对它的点击的拦截。
  2. 针对与手势相关的事件,SDK 首先通过 hook 系统类 UIGestureRecognizerinitWithTarget:action:addTarget:action: 这 2 个方法拿到 target 对象与 action 方法,然后再去 hook target 的 action 方法,从而能够拦截到手势相关的事件。
  3. 对于 UITableView、UICollectionView 某一行的点击,首先 hook 它们的 setDelegate: 方法,从而拿到 delegate 对象,然后再去 hook delegate 的 didSelectRowAtIndexPath: 方法即可。
  4. 对于 RN 页面中的点击,是通过 hook RN 框架中的 RCTUIManager 类的 setJSResponder:blockNativeResponder: 方法,具体原因可以看 这篇文章 的详细讲解。另外,为了避免 SDK 对 RN 框架产生依赖,通过 NSClassFromString(@"RCTUIManager") 来判断当前主工程是否使用了 RN 框架,如果未获取到此类,则不执行 hook 操作。
  5. 对于系统弹窗的点击这块,需要拦截到 UIAlertViewUIActionSheet 以及 iOS8 上新增的 UIAlertController 这 3 个弹窗的点击。对于前2个,只需要 hook 它们的 delegate 方法。而对于 UIAlertController 是没有提供相应的 delegate 方法的,这里可以通过 hook UIAlertAction 类的 actionWithTitle:style:handler: 类方法来拦截到其点击事件。
页面事件的拦截

对于页面事件的收集,主要通过 hook 系统类 UIViewController 的生命周期方法来实现,具体看下图:

img

滑动事件 & UIWebView加载事件

对于 iOS 中的滑动事件、UIWebView 的加载事件的收集,SDK 主要 hook 了 setDelegate: 方法以及 UIScrollViewDelegate、UIWebViewDelegate 中的方法。其原理上与 UITableView 的类似。具体见下图:

img

Event Collector

SDK 通过 AOP 层已经可以拿到执行各个事件的数据收集的时机,接下来就是执行真正的数据收集了,其中包括了对 点击事件的收集、页面事件的收集、滑动事件的收集等。

这些要收集的事件数据中包含一些基本信息,如:eventName、appKey、eventTime、sessionId、deviceId 等。除此之外,还有一些与特定事件相关的信息,例如对于 view 的点击事件,还需要收集与 view 的相关信息;对于列表行的点击,还需要收集点击行的 indexPath 信息;而对于 webView 加载事件则需要收集其 url 与 error 等信息。

接下来主要说一下 SDK 中点击事件的收集。

首先,对于 UIControl 控件与添加了 UITapGestureRecognizer 的 view,在收集它们的点击事件的数据时,重点收集了 2 部分内容:pageName、viewInfo。其中,pageName 是表明点击事件发生在哪个页面,一般用 viewController 的类名表示;viewInfo 是指当前被点击的view的一些相关信息,有:viewClass、viewPath、frame、title(如果有)、viewId 等。而 viewPath 是最关键的一项信息,能够唯一标识当前 view。

其次,对于导航栏上的点击事件的收集,与上面要收集的信息几乎是一样的,只是在收集 pageName 的数据时不一样。导航栏的点击事件默认的 pageName 是 UINavigationController,但是为了能够更好的分析用户行为,这里将 App 当前正在显示的页面作为其 pageName。

同理,收集系统弹窗的点击事件时,也将 App 当前正在显示的页面作为其 pageName。除此之外,由于同一个页面中可能会出现多个弹窗,它们的按钮文字信息有可能一样,比如经常会用 “确定”、“取消” 等文字,这时单纯靠按钮的 title 无法区分这些不同的弹窗,为了解决这个问题,又加入了系统弹窗的标题(title、message)。

最后,讲一下 SDK 中获取 viewPath 的实现逻辑,具体如下图所示:

img

Event Cache

这个模块主要负责所有事件数据的存取及序列化操作,具体可分为如下 3 部分:

  1. 采用双缓存的结构将数据存储在内存中。具体实现是,将新添加的事件数据先存储到全局数组 eventArray 中,等满足数据上报条件时,从 eventArray 中读出一部分数据并随机生成一个唯一的 eventsID,将其以 key-value 的形式存放到全局字典 popedEventDict 中,等这部分数据上传成功后再将 eventsID 对应项从 popedEventDict 中移除。
  2. 在某些情况下(App 即将被杀死、程序抛出异常),将内存中的数据以文件的形式持久化存储至磁盘中,以防数据丢失。
  3. 将从内存或文件中读取的数据执行 protobuf 序列化操作,以便后续的数据上传操作。

另外,为了确保对数据存取的多线程安全,上述操作全部都放到了同一个串行队列中执行。

Event Upload

这个模块的主要功能就是根据一定的数据上报策略,上报已收集的所有事件数据。数据上报主要包括对内存数据和本地文件这2部分,下面分别介绍一下它们的上报策略与实现思路。

内存数据的实时上报

首先,针对内存数据的上报策略有 2 个:

  1. 每隔 30 秒
  2. 每累积 10 条数据。

当满足上述条件之一时,会触发从内存中读取数据,并执行上传操作。对于内存数据的上传,单独创建了一个并发队列,并限制其最大并发数为 10,以防由于数据频繁时上报引起开启的线程数太多。

本地文件数据的上传

为了尽早的上传本地文件,以防用户卸载 App 造成本地数据的丢失,针对本地文件的上传策略有如下 3 个:

  1. App 冷启动
  2. App 进入前台
  3. App 进入后台

这里创建了一个单独的串行队列,来实现对本地文件进行逐个上传,即等上一个文件上传成功后,再触发下一个文件的上传。因此,上述 3 个触发时机并不会造成文件的重复上传,并以较小的代价完成本地文件的上传。

数据存取与上传的实现流程

其实上面已经讲了大致的实现思路,里面设计到了使用 GCD 队列来控制数据上传与保证多线程安全。为了更清晰的展示出这 2 部分的实现逻辑,简单画了一个流程图展示出来:

img

END

本篇文章主要介绍了无埋点数据 SDK 的整体设计,以及各个模块的功能和实现思路,其中重点介绍了执行事件收集所需 hook 的具体方法,和事件数据的存取与上报功能的实现流程。如果对本文有问题,请留言评论。

本文为转载。

作者:zerygao
链接:https://www.jianshu.com/p/69ce01e15042
来源:简书

本篇文章是基于 网易乐得无埋点数据SDK 总结而成。负责无埋点数据收集 SDK 的开发已经有半年多了,期间在组内进行过相关分享,现在觉得是时候拿出去和同行们交流下了。本篇主要讲一下SDK的整体实现思路以及关键的技术点。

SDK 已经具备不需要代码埋点就能 自动的动态可配的全面且正确 的收集用户在使用 App 时的所有事件数据。除此之外,还单独开发了与之配合的圈选SDK,能够在 App 端完成对界面元素的圈配以及 KVC 配置的上传。而界面元素圈配的工作完全可以交给用研与产品人员来做,减轻了开发人员的工作量。

SDK 已有的功能可以分为两大部分:

  • 基本事件数据的收集:基本事件的收集是指应用冷启动事件、页面事件、用户点击事件、ScrollView滑动事件等,这部分全部都是自动完成的,实现思路会在第一节中介绍。
  • 业务层数据的收集:业务层数据的收集是指对与业务功能相关的一些数据,例如:在用户点击提交订单按钮时,收集用户购买的物品以及订单总金额的数据。这种业务层数据的收集以往大多通过 代码埋点 的方式去做,本SDK则真正的实现了 无埋点 的去获取这些想要的业务数据。这部分的实现会在本文的第二节详细介绍。

SDK的整体实现思路

SDK 整体采用了 AOP(Aspect-Oriented-Programming)即面向切面编程的思想,就是动态的在函数调用的前后插入数据收集的代码。在 Objective-C 中的实现是基于 Runtime 特性的 Method Swizzling 黑魔法。

SDK 的数据收集功能的实现主要通过 Method Swizzlinghook 相应的方法。hook的方法大致可以分为3类:系统类的方法、系统类的Delegate方法、自定义类的方法。

系统类的方法

系统类的方法是指系统框架中提供的基础类的方法,如 UIApplicationUIViewController 等。SDK 在实现某些功能时,需要hook这些类的方法。例如在实现对页面事件的收集时,主要hookUIViewController 的生命周期的方法:viewDidLoadviewDidAppearviewDidDisappeardealloc

系统类的 Delegate 方法

系统类的 Delegate 方法主要指 UIKit 框架中提供的 Delegate 中的方法,如 UIScrollViewDelegate、UITableViewDelegate、UIWebViewDelegate 等。SDK 中的大多数功能都是通过hook这些协议中的方法来完成的。例如在实现列表元素点击事件的收集时,主要 hookUITableViewDelegate 中的 tableView:didSelectRowAtIndexPath: 方法。

自定义类的方法

顾名思义,自定义类的方法是指开发人员在工程中自已定义的类,而非系统类的方法。SDK的一些功能是通过hook 这些类的方法来实现。例如在SDK实现对手势操作的事件收集时,需要hook手势对象所指定的target 中的 action 方法,而 target 通常都是自定义类。其实hook系统类的 delegate 方法也可以看成是 hook 自定义类的方法,因为系统类的 delegate 方法大多都是需要在自定义类中实现。

这部分看起来是借助于 AOP 来添加数据收集的代码,但是在真正做的时候,也并没有想的那么简单,涉及到很多细节上的问题,例如:如何将导航栏与系统弹窗的点击事件归属到合适页面中、如何区分UIControlEventValueChanged事件、如何解决hook手势操作引起的性能问题等等。不过这部分内容并不是本篇文章的重点,因此这里不打算多说,之后会单独写一篇文章来讲述遇到的一些坑。

SDK的关键技术的实现

viewPath 及 viewId 的生成及优化

为了对 APP 中某个页面的某个 view 进行数据收集、统计与分析,首先就需要能够唯一的标识与定位这个视图,这可以说是数据收集 SDK 的一个重要前提。那么怎样去唯一的标识 APP 中的某个 view 呢?SDK 中使用了 viewPathviewId 来完成。

1. viewPath 的组成

其实整个 APP 的视图结构可以看成是一颗树(viewTree),树的根节点就是 UIWindow,树的枝干由UIViewControllerUIView组成,树的叶节点都是由UIView组成。

那么在viewTree中用什么信息来表示其中任意一个 view 的位置呢?很容易想到的就是使用目标 view 到根之间的每个节点的深度(层次)组成一个路径,而节点的深度(层次)是指此节点在父节点中的 index。这样确实能够唯一的表示此 view 了,但是有一个缺点:它的可读性很差。因此在此基础上又增加了每个节点的名称,节点的名称由当前节点的 view 的类名来表示。

因此,在 viewTree 中,由一个 view 到根节点之间的每个节点的名称与深度(层次)共同组成的信息构成了此 view 的viewPath。另外,由于在做 view 的统计分析时,都是以页面为单位的,因此 SDK 在生成 viewPath 时,只到 view 所在的 UIViewController 级别,而非根部的 UIWindow。这样做也在一定程度上减少了viewPath 的长度。

2. UITableViewCell/UICollectionCell 的深度表示

在 App 开发中,最常用而且最重要的控件就是UITableViewUICollectionView。针对这种可复用视图,里面会包含很多 Cell,而且 Cell 个数也不确定,那么里面的每一个 Cell 应该怎么去表示其深度呢?答案是indexPath。虽然每个 Cell 都可能被复用,但是不同的 Cell 都对应一个唯一的indexPath,因此完全可以使用indexPath值来表示其深度。

3. viewPath 的表示形式与示例

我们已经知道,viewPath就是由各节点的类名与深度组成,那么接下来就使用这些信息来表示出 viewPath。下面结合一个具体的示例来简单说一下,我随便从项目中找了一个:

路径中各个节点的类名是:

1
HYGHallSlideViewController-UIScrollView-HYGHallProductTableView-UITableViewWrapperView-HYGHallProductCell-UITableViewCellContentView-HYGHallProductView。

路径中各个节点的深度是:0-0-1-0-0:2-0-1

接下来就是将这两者放到一起来构成 viewPath,SDK 的表示方式如下:

1
viewPath:HYGHallSlideViewController-UIScrollView-HYGHallProductTableView-UITableViewWrapperView-HYGHallProductCell-UITableViewCellContentView-HYGHallProductView & 0-0-1-0-0:2-0-1

其实就是使用 & 连接符简单的拼接到一起。这样做可以方便将两者组合与分离开,便于后面的viewPath匹配。另外,网上还有一种类似于 xPath 的表示方式:

1
HYGHallSlideViewController[0]/UIScrollView[0]/HYGHallProductTableView[1]/UITableViewWrapperView[0]/HYGHallProductCell[0:2]/UITableViewCellContentView[0]/HYGHallProductView[1]

不过个人觉得xPath的方式稍微复杂了点,在组合以及拆分上都相对麻烦些。不过话说回来,viewPath的形式是次要的,大家可以按照各自喜欢的方式去表示就行,无须纠结于哪种形式更好。

4.针对 viewPath 的优化

4.1 优化节点的深度的计算方式

上面提到在计算各节点的深度时,是采用当前 view 位于其父 view 中的所有子 view 中的 index 值。不过在实际的开发中,viewTree 有时候会根据用户的操作有所变动。仍然举个栗子:

  • 假设一个 UIView 中有三个子 view,先后加入的顺序是:label、button1、button2,按照之前的计算方式,这 3 个子 view 的深度依次是:0、1、2。这时候用户点击了一个按钮,label1 从父 view 中被移除了。此时 UIView 只有 2 个子view:button1、button2,而且深度变为了:0、1。如图所示:

img

可以看出仅仅由于其中一个子view 被移除,却导致其它子 view 的深度都发生了变化。因此,SDK 为了在新增/移除某一 view 时,尽量减少对已有 view 的深度的影响,调整了对节点的深度的计算方式:采用当前 view 位于其父 view 中的所有 同类型 子 view 中的index 值。

我们再看一下上面的这个例子,最初 label、button1、button2 的深度依次是:0、0、1。在 label 被移除后,button1、button2 的深度依次为:0、1。可以看出,在这个例子中,label 的移除并未对 button1、button2 的深度造成影响,这种调整后的计算方式在一定程度上增强了 viewPath 的抗干扰性。

另外,调整后的深度的计算方式是依赖于各节点的类型的,因此,此时必须要将各节点的名称放到viewPath中,而不再是仅仅为了增加可读性。

4.2 viewPath 针对 Swift 的优化

众所周知,Swift文件在获取其类名时,会自动添加此文件所在的Module名前缀:如果Swift文件在主工程中,则会添加工程的名字;如果是在某个组件中,并且项目开启了 use frameworks! 选项,则会添加组件的名字。总的来说,在含有swift 的项目中(包括纯 swift/OC 与 swift 混编),viewPath中会包含各 Swift 文件的ModuleName,那么在如下情况下:

  • 某个 OC 文件被使用 Swift 重写了
  • 某个 Swift 文件被从主工程移至某个组件库中,或者从组件库移至主工程中
  • 主工程在引用组件库时,在开启与关闭use frameworks!之间进行切换

上述3种情况下,文件的类名都会由于ModuleName而发生变化,进而会导致 viewPath 的改变,工程文件在结构上的调整都可能会直接对viewPath造成影响。

实际开发中,特别是对于较老的OC项目,经常会对项目的OC文件使用Swift重写。因此 SDK 有必要去避免viewPath因为这类情况而发生变化。

其实这个问题的解决方案很简单,既然是由于类名中的ModuleName前缀的改变造成的,那么就干脆在生成viewPath时,去掉所有的SwiftModuleName前缀。这种做法能够解决对viewPath的影响,但是细心的人可能会意识到另一个隐藏的问题:如果在不同的组件库中,两个不同的视图或控制器具有相同的名字(在Swift中是允许的,因为有Module进行区分),这种情况下,viewPath是否存在无法区分的情况?

其实经过仔细考虑,这个担忧有点多余,因为就算两个Module中的视图或控制器名字一样,但是他们里面的视图结构会有所不同,进而深度也不一样,viewPath也不会完全相同。

4.3 在包含子VC时,优化VC的深度的计算

前面提到,viewPath只表示到距离 view 最近的一个 VC,VC 的深度的计算也是此 VC 的 view 所在的父 view 的所有子 view 中的深度。在实际的 iOS 开发中,可能会经常使用addChildViewController:添加多个子 VC 来实现复杂的页面,但是在包含子 VC 时,VC 的深度计算就有可能会存在问题。还是举一个简单的栗子:

  • 假设一个 containerVC 中包含4个子VC:VC1、VC2、VC3、VC4。在每个子VC首次被展示时,子VC会先被add进来,而子 VC 的 view 也会被 add 到一个scrollView 上。这时候这几个子VC首次的查看顺序的不同将会导致它们的深度的变化:如果查看顺序是:VC1、VC2、VC3、VC4,那么它们的深度依次 为:VC1(0)、VC2(1)、VC3(2)、VC4(3);如果查看顺序是:VC3、VC1、VC4、VC2,深度则变成了:VC1(1)、 VC2(3)、VC3(0)、VC4(2)。这种情况导致 viewPath 不可靠且无法保证唯一性。

SDK 为了解决上述情况,调整了 VC 的深度的计算:不再采用其 view 的深度,而是直接使用固定的0。因为 VC 已经是viewPath的根级别了,它的深度信息已经不重要了。

不过这种方案会引起另一个小问题,如果上述子 VC 的 VC1 和 VC2 是同一个类的不同实例,那么他们内部的视图结构是完全一样的,这时候如果使用固定的 VC 深度(0),通过viewPath就无法区分具体是哪个子 VC 的 view 了。针对这种同一类的不同实例,如果想进一步区分它们,SDK 采用了另一个方案:页面别名。

5. viewId 的生成

viewPath 已经能够唯一标识某个 view 了,为何还需要viewId呢?其实主要原因是:viewPath 的长度不固定,而且一般都会比较长,不便于后台使用它作为 view 的唯一标识。因此 SDK 使用viewPath信息通过MD5加密生成一个固定长度的值作为viewId

6. viewPath 与 viewId 重复时的解决方案

经过对viewPath的优化,SDK 已经尽可能的保证了viewPath的稳定性。但是并不表示只依靠viewPath就能区分所有的点击事件。有时同一个viewPath的 view 具有不同的表现形式与作用,例如下面的情况:

  • 同一个按钮在不同的状态下,显示不同的文字。例如:一个按钮在未添加商品前显示“添加”;添加了商品之后,立刻显示成“清除”
  • 同一个view上具有多处点击事件,例如 SegmentControl、UISwitchUIStepper

上面的这2种情况,都是同一个viewPath对应多个事件,此时如果只使用viewPath无法区分出不同的状态或事件。

针对这类问题,SDK 的解决方案是:viewPath + “其它信息” 。这里的 “其它信息” 是视不同情况而定的,比如: 在上面的情况1中,“其它信息” 就是按钮的 title。在情况2中,“其它信息” 是 SegmentControl 的 selectedIndex 和 UISwitch 的 isOn 属性的值。SDK 在进行数据收集时,会上传 view 的这些信息,再结合圈选SDK就能让后台在做统计时区分出这些不同的事件了。

关于“其它信息”,再补充一点,除了 SDK 事先知道要获取的信息之外,还有一类就是业务数据。例如:有一个商品列表页,每一行显示一个商品,如果后台想统计的不是列表中每一行的点击,而是每个商品 的点击,那么此时的“其它信息”就应该是productId 了。关于 SDK 对业务层数据的获取与上报请看下面的介绍。

SDK无埋点业务数据收集的实现

讲完了 viewPath 之后,接下来详细介绍下 SDK 的另一个关键技术:基于 viewPathKVC 实现 SDK 的无埋点业务数据收集功能。首先,先简单分析一下传统的 代码埋点 存在的缺点,大致有以下几个:

  • 埋点代码与业务逻辑代码混合在一起,增加了代码的维护成本;
  • 埋点代码需要跟随APP版本一起发布,耽误数据的收集与统计;
  • 埋点时存在错埋、漏埋等情况,无法动态更新及添加;

为了解决上述的 代码埋点 的缺陷,SDK 实现了真正意义上的 无埋点 来对业务数据进行收集。

1. 无埋点的实现架构

SDK 的无埋点功能的实现主要依赖于 viewPathKVCviewPath前面已经介绍了,它主要用于标识viewTree中的某个 view。而KVC对于 iOS 开发者也不陌生,堪称 iOS 开发中的黑魔法之一。通过KVC我们能够通过 key 或 keyPath 直接访问对象的属性,而不需要调用明确的存取方法。关于KVC如果不太了解,请自行学习,这里不再过多阐述。

那么如何实现不需要代码埋点就能随意获取想要的业务数据呢?先看一下 SDK 的无埋点技术的整体架构图:

img

从上图可以看出,在实现 SDK 的无埋点数据收集时,主要分为3步:上传KVC配置、请求KVC配置、业务数据的收集与上报。

2. 什么是 KVC 配置

在上图中出现了 KVC配置,那么下面先简单介绍下什么是KVC配置。其实 KVC配置 就是一些用来描述 App 应该在什么时机去收集什么数据的信息,包含的主要信息有:

  • appKey:用来标识是哪个应用
  • appVersion:用来标识应用的版本号
  • viewEvent:标识某个事件类型(收集时机),例如:ButtonClick、ListItemClick、ViewTap等
  • viewPath:目标 view 在viewTree中的信息
  • keyPath:目标 view 与要收集的业务数据间的关联路径,用于KVC取值
  • keyName:为要收集的业务数据定义一个key,最终组成 key-value 的形式上报。用于区分多个收集的数据

3. KVC配置的上传与下发

  • 上传KVC配置
    • 利用 圈选SDK 上传 KVC配置 的操作对于用户是透明的,主要由开发人员进行上传与管理。此操作可以在任何时候进行,在想要收集某个或某些版本的 App 中的业务数据时,上传相应的KVC配置信息至后台即可,达到了根据需要动态可配的效果。
  • 请求KVC配置
    • SDK 在初始化时会触发 KVC配置 的请求操作,从后台拉取 App 当前版本对应的所有KVC配置,并将请求结果缓存起来,以提供给下一步使用。

4. 业务数据的收集与上报

这一部分是 SDK 无埋点技术的核心,接下来详细介绍这部分的实现逻辑。它的实现流程如下:

img

这个环节的核心是基于viewPath的 view 匹配,主要实现是通过循环遍历viewPath的每个节点的信息与当前 view 及其父view 依次进行匹配。因此这一步会产生一定的时间与性能消耗。为了尽可能减少这部分的操作,SDK 中使用了一些方式进行优化,其中一个就是基于缓存view的优化。

4.1 基于缓存view的优化

SDK 采用缓存上一次匹配成功的 view 信息的方式,来减少一些不必要的viewPath匹配操作。这里主要缓存的 view 信息有:

  • targetView:上一次通过viewPath匹配成功的 view 对象。
  • indexPath: 上一次通过viewPath匹配成功的 view 的indexPath,如果没有则为nil。
1. viewEvent 匹配

第一步先进行事件类型的匹配。如果KVC配置信息指定的 viewEvent 是 ButtonClick,那么可以轻松的过滤掉 ListItemClick、ViewTap 等其它事件。这一步能够过滤一大部分事件,只有事件类型匹配成功才继续进行下一步。

2. targetView 匹配

接下来就是将缓存的 targetView 与当前 view 进行比较。如果两者指向同一对象,则进行第3步,否则直接进入第4步

3. indexPath 匹配

有人可能不明白为何要添加这一步呢?其实这一步也很重要,是对第2步的补充,主要是用来处理 Cell 可复用性的情况。

如果第2步中缓存的 targetView 是 Cell 或 Cell 中的某个 subview,那么第2步的匹配成功,并不能保证当前 view 就是我们真正想匹配的 view。这个可能不太容易理解,还是举个简单的例子来说明一下:

  • 假如一个 Cell 中有一个 button,在第1行的 button 被点击时,通过viewPath匹配成功 了,那么这时 targetView 缓存了第1行的 button 对象。接下来向下滑动列表,第一行被划出屏幕,第10行划入屏幕,同时第10行复用了第1行的 Cell,这时再点击 button 去匹配时,由于 Cell 复用的原因,targetView 与当前 button 肯定指向同一个对象,但是却不是我们真正想匹配的第1行的 button。可以看出:在有 Cell 复用的情况下,无法确定第2步的结果一定正确。

因此,在第2步的基础上又增加了indexPath匹配。indexPath的匹配逻辑为:如果缓存的indexPath不为nil并且与当前view的indexPath不相等,则进入第4步;否则表明当前的 view 就是上次刚刚匹配成功的,也就没必要进行viewPath匹配,可以直接进入第5步。

4. viewPath 匹配

这一步就是对当前的 view 及其父view 与KVC配置中的viewPath的各个节点进行逐个匹配。由于是一个循环操作,因此会有一定的时间消耗,其实在这部分的匹配中,也做了一些简单的优化。在真正进入循环匹配之前,先进行如下3步判断:

  • 判断 view 类名是否相等;
  • 判断 view 所在的 viewController 类名是否相等;
  • 判断 view 所在的 window 类名是否相等;

上述的3个判断也能过滤很多不必要的匹配。只有这3个判断均通过后,才进行viewPath循环匹配。

5. KVC 取值与上报

到了这一步,就已经验证了数据收集的时机是正确的。接下来就可以直接使用 KVC配置信息中的keyPath调用 valueForKeyPath: 方法获取对应的值。如果值不为nil,就与 keyName 组成一个键值对,放到当前的事件数据中一起上报上去。这样后台就可以通过key去查找到相应的业务数据了。

上面只是简要介绍了一下匹配时的逻辑,在实际开发中还会添加对 cell 的indexPath通配的情况的处理,由于文章篇幅这里不再详细讲解。

5. 增加对 KVC 的异常处理

SDK 的无埋点功能的实现其实主要依赖于KVC,但是众所周知,KVC是非常危险的,很容易造成程序崩溃。例如一旦 key 或 keyPath 所对应的属性名不存在,立刻会导致程序抛出一个NSUndefinedKeyException异常,如果应用没有处理此异常,程序就会Crash。

因此,为了避免程序Crash,SDK 内部增加了对KVC异常的处理。具体实现是给 NSObject 增加一个 Category ,重写 valueForUndefinedKey: 方法,并在方法中return nil

1
2
3
4
5
6
@implementation NSObject (KVCExceptionHandler)
- (nullable id)valueForUndefinedKey:(NSString *)key
{
return nil;
}
@end

其它关键技术

当然,SDK 的实现中还有很多关键技术点,比如:SDK 对 RN 页面的数据收集、页面别名方案的实现、Method SwizzlingAspects的兼容等。由于本文的篇幅已经很长了,而且考虑到大家读文章的耐性都不会太长,所以这里就先不讲解了,后续会再写文章单独介绍。

END

文章写了这么多,其实主要介绍了 SDK 中的两个关键技术点,希望对你们能有一些参考价值。另外,如果有人对本文的方案有更好的建议,欢迎一起讨论学习。

最后,要特别感谢我的同事王佳乐,由于他对文章的排版与校对工作,才使得本文能更好的展示给大家。同时也要感谢组内的所有同事,在我开发遇到困难时,给予了我很多的帮助。

Q & A

关于对本文内容提出的一些问题,将全部记录在这里(简书评论里的除外),并进行统一解答。

Q1: SDK 都使用KVC配置获取业务数据,是否会增加维护KVC配置的工作?

A1: 会有对 KVC配置 的维护与管理工作,不过 SDK 也简化了这块的管理工作。

一般来说,上传的所有的 KVC配置 需要与 App 的版本相对应,因为 App 版本不同会直接导致keyPath可能不一样。所以与 KVC配置 相关的工作有如下2个:

  1. 针对当前 App 版本上传相应的 KVC配置,以获取想要的业务数据
  2. 当 App 新版本发布时,需要对之前版本上的 KVC配置 逐一验证,是否仍然适用于新版本。如果仍然适用,则直接在管理后台上把新的版本号添加到此 KVC配置;如果不再适用,则对新版本再上传一个新的KVC配置。

从上面可以看出,在 App 版本不断迭代的过程中,KVC配置 会越来越多,相应的维护与管理工作也相当繁琐。

为了解决这个痛点,SDK 中增加了一种方案来避免这种重复且繁琐的工作。具体的方案是:

  • 在上传 KVC 配置时,指定某个区间的版本,或者不指定具体的版本(即应用到当前所有版本上);
  • SDK 在使用KVC配置获取业务数据失败时,添加相关的错误日志,并上报上去。其中错误日志里包含了appKeyappVersionkeyPath等信息,这样就能在后台清晰的看到哪些 KVC配置 在哪个 App 版本上存在问题;
  • 使用脚本监控与KVC相关的错误日志。如果监控到有错误日志上报,则发送邮件通知给相关人员;

因此,SDK 采用此方案优化之后,KVC配置 的管理工作就只有1个了:

  • 根据Log信息快速找到对应的 KVC配置,并上传一个针对新版本的 KVC配置

Q2: 对于 “内容与位置” 可能会随时间而变动时,如何实现数据收集与统计?

A2: 使用圈选SDK与数据SDK共同完成动态数据的收集与统计

这个问题在实际产品中也比较常见,比如 App 首页的内容大多是通过后台配置的。
这个问题其实可以转化或分解成如下的2个情况:

  • 同一位置会显示不同的内容
  • 同一内容会显示在不同的位置

注意,这2个并非同一个,它们分别对应于不同的场景,同时数据收集的方案也有所不同。

另外,“位置” 可以是在列表中,也可以是非列表中的,不过这个对整体的方案没有太大影响,仅仅是在不关心位置时viewPath中的通配符位置不同。

A2.1 同一位置显示不同的内容

例子:在 App 首页有一个展示最近活动的位置,先展示活动1的图片,过一段时间运营人员又配成活动2的图片。如何统计活动1、活动2各自的点击量?

针对这种场景,SDK 的解决方案是:“关心位置” + “关心内容”
“关心位置” 的意思是只使用当前的位置,具体表现是viewPath中不包含任何通配符;“关心内容” 的意思是指定一个想要统计的内容。

整个过程可以分解为如下3个环节:

  • 圈选SDK上传“关心位置”的KVC配置。KVC配置中指定获取活动的urlkeyPath
  • 数据SDK在活动发生点击时,收集当前活动对应的url,并跟随点击事件一起上报。
  • 圈选SDK上传“关心位置” + “关心内容”的圈选配置,关心的内容指定为想要统计的活动的url值。
A2.2 同一内容显示在不同的位置

例子:App 首页有4个固定的入口,假设其中一个叫“热门推荐”,那么根据后台配置的顺序不同,“热门推荐”可能被显示在4个位置中的任何1个,即一段时间显示在第1个,过一段时间可能显示在第2个位置。这时如何统计出“热门推荐”的点击量?

针对这种场景,SDK 的解决方案是:“不关心位置” + “关心内容”
“不关心位置” 是指viewPath中含有通配符,用于表示viewTree中的多个位置。例如想要匹配列表所有行时,则将viewPath中的indexPath替换为通配符。

这个问题的解决过程也分为如下3步:

  • 圈选SDK上传“不关心位置”的KVC配置。KVC配置中指定获取入口的 title 的keyPath
  • 数据SDK在4个中任何一个入口被点击时,都去收集入口的 title,并跟随点击事件一起上报。
  • 圈选SDK上传“不关心位置” + “关心内容”的圈选配置,关心的内容指定为“热门推荐”。

到这里,数据收集与圈选配置的工作都已经做完了,接下来就是后台的数据统计了。
上述2种情况对后台进行统计没有区别,都使用一个统计方案,这里也介绍一下后台大概的统计思路:

  • 拿到第3步中上传的圈选配置,根据viewPath“关心的内容” 生成一个正则表达式,然后从数据 SDK 上报的原始数据中进行正则匹配,进而统计出相应数据。

本文为转载:

作者:Joy_xx
链接:https://juejin.cn/post/6844903498383900680
来源:稀土掘金

在这样一个注重用户体验的时代,APM 技术快速发展,国内更是百花齐放,最近对各个公司的 APM 产品有一个调研,并在此基础上进行了自己的实践。这里就从 iOS 的角度出发,谈谈自己对移动端 APM 的技术上的理解,并提供相对应的实例。

何为 APM

APM 的全称是Application performance management,即应用性能管理,通过对应用的可靠性、稳定性等方面的监控,进而达到可以快速修复问题、提高用户体验的目的。

国内各大公司都有自己的一套监控体系,这个系统可能是自己研发,也可能是第三方提供,当然对于这个数据为王的时代,很多有实力的公司倾向于自主研发,掌握核心数据。比较有代表性的 APM 产品有:听云、阿里百川、腾讯 bugly、NewRelic、OneAPM、网易云捕等

说到监控,那么指标是我们所关注的呢?如下所示

  • 网络请求:成功率、状态码、流量、网络响应时间、HTTP与HTTPS的 DNS 解析、TCP握手、SSL握手(HTTP除外)、首包时间等时间
  • 界面卡顿、卡顿堆栈
  • 崩溃率、崩溃堆栈
  • Abort 率:也就是由于内存过高的等原因,被系统杀死的情况
  • 交互监控:页面加载时间、页面的交互痕迹
  • 维度信息:地域、运营商、网络接入方式、操作系统、应用版本等
  • 其他:内存、帧率、CPU使用率、启动时间、电量等

聊聊原理

卡顿检测

当应用发生卡顿的时候,一般会伴随着掉帧,所以帧率是最容易想到的指标来判断卡顿。对于线下的测试环境,我们可以使用帧率来对开发做一些提示,告诉他们可能发生了卡顿。但是帧率不稳定性较高,所以一般会采取另一种方式来做卡顿检测。那就是Runloop,对于细节可以查看 Runloop 源码,会发现对于事件的处理主要就是在kCFRunLoopBeforeSourceskCFRunLoopBeforeWaiting状态之间,还有kCFRunLoopAfterWaiting之后。那我们就可以对两个状态进行监控,如果消耗时间太久,就代表着卡顿的发生。

runloop

上图摘自阿里百川,如图所示,我们会对卡顿次数做一个判断,如果次数为1,但时间超时,则为单次耗时较长的卡顿,如果次数到达阀值,则证明是连续短时间卡顿。

当卡顿发生之后,我们为了定位,会收集当时的一个堆栈情况,在此你可以使用 PLCrashReporter 来做,也可以自己研发一个堆栈收集库(可参考这里来做

对于实例,网上已经有很多开源的项目,你可以参考这个

崩溃检测

对于崩溃的情况,一般是由 Mach异常或 Objective-C 异常(NSException)引起的。我们可以针对这两种情况抓取对应的 Crash 事件。

Mach 异常捕获

如果想要做mach 异常捕获,需要注册一个异常端口,这个异常端口会对当前任务的所有线程有效,如果想要针对单个线程,可以通过 thread_set_exception_ports注册自己的异常端口,发生异常时,首先会将异常抛给线程的异常端口,然后尝试抛给任务的异常端口,当我们捕获异常时,就可以做一些自己的工作,比如,当前堆栈收集等。

对于如何注册一个异常端口,这里有示意图和 PLCrashReporter 可以参考

machport

Unix 信号捕获

对于Mach 异常,操作系统会将其转换为对应的 Unix信号,所以如果你对Mach不熟悉的话,也可以通过注册signalHandler的方式来做信号异常。对于实例,你可以参考这里

1
2
3
4
5
6
7
8
9
10
11
signal(SIGHUP, signalHandler);
signal(SIGINT, signalHandler);
signal(SIGQUIT, signalHandler);

signal(SIGABRT, signalHandler);
signal(SIGILL, signalHandler);
signal(SIGSEGV, signalHandler);
signal(SIGFPE, signalHandler);
signal(SIGBUS, signalHandler);
signal(SIGPIPE, signalHandler);

NSException 捕获

对于NSException异常,也比较容易处理,通过注册NSUncaughtExceptionHandler捕获异常信息即可,将拿到的NSException细节写入Crash日志,上传到后台做数据分析

1
2
3
// register the uncaught exception handler
NSSetUncaughtExceptionHandler(&handler);

Abort 率检测

目前对于内存过高被杀死的情况是没有办法直接统计的,一般通过排除法来做百分比的统计,原理如下

  • 程序启动,设置标志位
  • 程序正常退出,清楚标志
  • 程序Crash,清楚标志
  • 程序电量过低导致关机,这个也没办法直接监控,可以加入电量检测来辅助判断
  • 第二次启动,标志位如果存在,则代表Abort一次,上传后台做统计

abort

交互监控

对于页面的加载时间,这个比较容易实现,直接通过Runtime hook对应的生命周期方法即可,比如 viewDidLoadviewWillAppear

对于用户的交互痕迹,比如点击了那个按钮、跳转到了那个页面,这些信息偏于用户行为的收集,我们也独立研发了一个无埋点的SDK,专门来做用户行为数据的收集与分析,核心也是基于 hook AOP的思想。细节可以参考我同事的作品

网络监控

对于成功率、状态码、流量,以及网络的响应时间之类的,我们可以主要可以通过两种方式来做

  • 针对URLConnectionCFNetworkNSURLSession三种网络做Hookhook的具体技术可以是method swizzle 也可以是ProxyFishhook之类的
  • 也可以使用 NSURLProtocol 对网络请求的拦截,进而得到流量、响应时间等信息,但是NSURLProtocol有自己的局限,比如NSURLProtocol只能拦截NSURLSessionNSURLConnection以及UIWebView,但是对于CFNetwork则无能为力

对于第一种方式可以Hook哪些方法的,可以参考这个图

网络监控

但是,因为我们所使用的URLConnectionCFNetworkNSURLSession底层都是 BSDSocket,所以可以尝试在socket上动手脚来实现效果,类似于通过ViewController的生命周期方法来统计页面加载时间的做法,我们Hook socket相关的方法来做,比如通过hook socket连接时的 connect方法,拿到tcp握手的起始时间,通过hook SSLHandshake方法,在SSLHandshake执行的时候拿到 SSL握手的起始时间等。目前听云已经提供了 HTTP 的分段时间查询功能,大家去体验下

1
2
3
4
int	connect(int, const struct sockaddr *, socklen_t) __DARWIN_ALIAS_C(connect);

OSStatus SSLHandshake(SSLContextRef ctx)
复制代码

但是对于 iOS 9 Apple 加入 ATS 新特性,并要求开发者使用 HTTPS,我在 iOS9、10上对 HTTPS 网络请求Hook socket方法时候,有一些方法hook 失效,猜想应该是Apple 进行了加固、加密,导致一些系统方法没办法hook,所以在 iOS9、10 上无法通过socket来取得HTTPS网络的分段时间(纠正:fishhook 无法 hook socket 的原因:https://github.com/facebook/fishhook/issues/40)

不过apple在 iOS 10 推出一个API,可以在 iOS10 版本以上进行网络信息的收集

1
2
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics 

打印结果如下

1
2
3
4
5
6
7
8
9
10
11
(Fetch Start) 2017-02-24 09:03:06 +0000
(Domain Lookup Start) 2017-02-24 09:03:06 +0000
(Domain Lookup End) 2017-02-24 09:03:06 +0000
(Connect Start) 2017-02-24 09:03:14 +0000
(Secure Connection Start) 2017-02-24 09:03:14 +0000
(Secure Connection End) 2017-02-24 09:03:16 +0000
(Connect End) 2017-02-24 09:03:16 +0000
(Request Start) 2017-02-24 09:03:16 +0000
(Request End) 2017-02-24 09:03:16 +0000
(Response Start) 2017-02-24 09:03:16 +0000
(Response End) 2017-02-24 09:03:16 +0000

当然,对于网络各层次的时间获取,如果你有好的方案,希望您可以留言告知。同时对于一些维度信息和内存等基础指标,很容易获取,这里就不细谈了

大礼包

在调研和学习APM技术的过程中,发现了很多优秀的博客,所以在此推荐给大家,有需要的可以自取

编号 地址 备注
1 https://developer.apple.com/cn/app-store/review/guidelines/#design Apple审核文档
2 https://help.apple.com/app-store-connect/#/dev28d17ed35 App store的操作说明文档
3 https://developer.apple.com/design/human-interface-guidelines/ios/overview/themes/ iOS 人机交互文档
4 https://developer.apple.com/wwdc19/ WWDC 发布视频
5 http://appstoreconnect.apple.com App store 后台
6 https://developer.apple.com/account/resources/certificates/list App证书管理后台
7 https://idmsa.apple.com/IDMSWebAuth/signin.html?path=%2Fcontact%2Fapp-store%2F%3Ftopic%3Dexpedite&appIdKey=891bd3417a7776362562d2197f89480a8547b108fd934911bcbea0110d07f757 反馈及申请加速后台
8 [https://developer.apple.com/library/archive/navigation/#section=Resource%20Types&topic=Sample%20Code](https://developer.apple.com/library/archive/navigation/#section=Resource Types&topic=Sample Code) Apple 官方技术文档
9 https://help.apple.com/xcode/mac/8.0/#/devc8c2a6be1 Apple开发工具说明文档
10 https://developer.apple.com/library/archive/releasenotes/General/WhatsNewIniOS/Introduction/Introduction.html#//apple_ref/doc/uid/TP40008244-SW1 iOS系统版本文档
11 https://appleid.apple.com/account/manage 账号更新、换绑

软件架构模式

转载:原文地址:软件架构模式

简介

对程序员来说很常见一种情况是在没有合理的程序架构时就开始编程,没有一个清晰的和定义好的架构的时候,大多数开发者和架构师通常会使用标准式的传统分层架构模式(也被称为多层架构)——通过将源码模块分割为几个不同的层到不同的包中。不幸的是,这种编码方式会导致一系列没有组织性的代码模块,这些模块缺乏明确的规则、职责和同其他模块之间的关联。这通常被称为架构大泥球。

应用程序缺乏合理的架构一般会导致程序过度耦合、容易被破坏、难以应对变化,同时很难有一个清晰的版本或者方向性。这样的结果是,如果你没有充分理解程序系统里每个组件和模块,就很难定义这个程序的结构特征。有关于程序的部署和维护的基本问题都难以回答,比如:程序架构是什么规模?应用程序有什么性能特点?应用程序有多容易应对变化?应用程序的部署特点是什么?架构是如何反应的?

架构模式帮助你定义应用程序的基本特征和行为。例如,一些架构模式会让程序自己自然而然地朝着具有良好伸缩性的方向发展,而其他架构模式会让程序朝着高度灵活的方向发展。知道了这些特点,了解架构模式的优点和缺点是非常必要的,它帮助我们选择一个适合自己特定的业务需求和目标的的程序。

作为一个架构师,你必须证明你的架构模式的决策是正确的,特别是当需要选择一个特定的体系结构模式或方法的时候。这本迷你书的目的就是给你足够的信息让你去做出正确的架构决策。

第一章 分层架构

分层架构是一种很常见的架构模式,它也叫N层架构。这种架构是大多数Jave EE应用的实际标准,因此很多的架构师,设计师,还有程序员都知道它。许多传统IT公司的组织架构和分层模式十分的相似。所以它很自然的成为大多数应用的架构模式。

模式分析

分层架构模式里的组件被分成几个平行的层次,每一层都代表了应用的一个功能(展示逻辑或者业务逻辑)。尽管分层架构没有规定自身要分成几层几种,大多数的结构都分成四个层次:展示层,业务层,持久层,和数据库层。如表1-1,有时候,业务层和持久层会合并成单独的一个业务层,尤其是持久层的逻辑绑定在业务层的组件当中。因此,有一些小的应用可能只有3层,一些有着更复杂的业务的大应用可能有5层或者更多的分层。

分层架构中的每一层都着特定的角色和职能。举个例子,展示层负责处理所有的界面展示以及交互逻辑,业务层负责处理请求对应的业务。架构里的层次是具体工作的高度抽象,它们都是为了实现某种特定的业务请求。比如说展示层并不需要关心怎样得到用户数据,它只需在屏幕上以特定的格式展示信息。业务层并不关心要展示在屏幕上的用户数据格式,也不关心这些用户数据从哪里来。它只需要从持久层得到数据,执行与数据有关的相应业务逻辑,然后把这些信息传递给展示层。

1-1

分层架构的一个突出特性是组件间关注点分离 (separation of concerns)。一个层中的组件只会处理本层的逻辑。比如说,展示层的组件只会处理展示逻辑,业务层中的组件只会去处理业务逻辑。多亏了组件分离,让我们更容易构造有效的角色和强力的模型。这样应用变的更好开发,测试,管理和维护。

关键概念

注意表1-2中每一层都是封闭的。这是分层架构中非常重要的特点。这意味request必须一层一层的传递。举个例子,从展示层传递来的请求首先会传递到业务层,然后传递到持久层,最后才传递到数据层。

1-2

那么为什么不允许展示层直接访问数据层呢。如果只是获得以及读取数据,展示层直接访问数据层,比穿过一层一层来得到数据来的快多了。这涉及到一个概念:层隔离。

层隔离就是说架构中的某一层的改变不会影响到其他层:这些变化的影响范围限于当前层次。如果展示层能够直接访问持久层了,假如持久层中的SQL变化了,这对业务层和展示层都有一定的影响。这只会让应用变得紧耦合,组件之间互相依赖。这种架构会非常的难以维护。

从另外一个方面来说,分层隔离使得层与层之间都是相互独立的,架构中的每一层的互相了解都很少。为了说明这个概念的牛逼之处,想象一个超级重构,把展示层从JSP换成JSF。假设展示层和业务层的之间的联系保持一致,业务层不会受到重构的影响,它和展示层所使用的界面架构完全独立。

然而封闭的架构层次也有不便之处,有时候也应该开放某一层。如果想往包含了一些由业务层的组件调用的普通服务组件的架构中添加一个分享服务层。在这个例子里,新建一个服务层通常是一个好主意,因为从架构上来说,它限制了分享服务访问业务层(也不允许访问展示层)。如果没有隔离层,就没有任何架构来限制展示层访问普通服务,难以进行权限管理。

在这个例子中,新的服务层是处于业务层之下的,展示层不能直接访问这个服务层中的组件。但是现在业务层还要通过服务层才能访问到持久层,这一点也不合理。这是分层架构中的老问题了,解决的办法是开放某些层。如表1-3所示,服务层现在是开放的了。请求可以绕过这一层,直接访问这一层下面的层。既然服务层是开放的,业务层可以绕过服务层,直接访问数据持久层。这样就非常合理。

1-3

开放和封闭层的概念确定了架构层和请求流之间的关系,并且给设计师和开发人员提供了必要的信息理解架构里各种层之间的访问限制。如果随意的开放或者封闭架构里的层,整个项目可能都是紧耦合,一团糟的。以后也难以测试,维护和部署。

示例

为了演示分层架构是如何工作的,想象一个场景,如表1-4,用户发出了一个请求要获得客户的信息。黑色的箭头是从数据库中获得用户数据的请求流,红色箭头显示用户数据的返回流的方向。在这个例子中,用户信息由客户数据和订单数组组成(客户下的订单)。

用户界面只管接受请求以及显示客户信息。它不管怎么得到数据的,或者说得到这些数据要用到哪些数据表。如果用户界面接到了一个查询客户信息的请求,它就会转发这个请求给用户委托(Customer Delegate)模块。这个模块能找到业务层里对应的模块处理对应数据(约束关系)。业务层里的customer object聚合了业务请求需要的所有信息(在这个例子里获取客户信息)。这个模块调用持久层中的 customer dao 来得到客户信息,调用order dao来得到订单信息。这些模块会执行SQL语句,然后返回相应的数据给业务层。当 customer object收到数据以后,它就会聚合这些数据然后传递给 customer delegate,然后传递这些数据到customer screen 展示在用户面前。

1-4

从技术的角度来说,有很多的方式能够实现这些模块。比如说在Java平台中,customer screen 对应的是 (JSF) Java Server Faces ,用 bean 组件来实现 customer delegate。用本地的Spring bean或者远程的EJB3 bean 来实现业务层中的customer object。上例中的数据访问可以用简单的POJP’s(Plain Old Java Objects),或者可以用MyBatis,还可以用JDBC或者Hibernate 查询。Microsoft平台上,customer screen能用 .NET 库的ASP模块来访问业务层中的C#模块,用ADO来实现用户和订单数据的访问模块。

注意事项

分层架构是一个很可靠的架构模式。它适合大多数的应用。如果你不确定在项目中使用什么架构,分层架构是再好不过的了。然后,从架构的角度上来说,选择这个模式还要考虑很多的东西。

第一个要注意的就是 污水池反模式(architecture sinkhole anti-pattern)。 在这个模式中,请求流只是简单的穿过层次,不留一点云彩,或者说只留下一阵青烟。比如说界面层响应了一个获得数据的请求。响应层把这个请求传递给了业务层,业务层也只是传递了这个请求到持久层,持久层对数据库做简单的SQL查询获得用户的数据。这个数据按照原理返回,不会有任何的二次处理,返回到界面上。

每个分层架构或多或少都可能遇到这种场景。关键在于这样的请求有多少。80-20原则可以帮助你确定架构是否处于反污水模式。大概有百分之二十的请求仅仅是做简单的穿越,百分之八十的请求会做一些业务逻辑操作。然而,如果这个比例反过来,大部分的请求都是仅仅穿过层,不做逻辑操作。那么开放一些架构层会比较好。不过由于缺少了层次隔离,项目会变得难以控制。

模式分析

下面的的表里分析了分层架构的各个方面。

整体灵活性

评级:低
分析:总体灵活性是响应环境变化的能力。尽管分层模式中的变化可以隔绝起来,想在这种架构中做一些也改变也是并且费时费力的。分层模式的笨重以及经常出现的组件之间的紧耦合是导致灵活性降低的原因。

易于部署

评级:低
分析:这取决于你怎么发布这种模式,发布程序可能比较麻烦,尤其是很大的项目。一个组件的小小改动可能会影响到整个程序的发布(或者程序的大部分)。发布必须是按照计划,在非工作时间或者周末进行发布。因此。分层模式导致应用发布一点也不流畅,在发布上降低了灵活性。

可测试性

评级:高
分析:因为组件都处于各自的层次中,可以模拟其他的层,或者说直接去掉层,所以分层模式很容易测试。开发者可以单独模拟一个展示组件,对业务组件进行隔绝测试。还可以模拟业务层来测试某个展示功能。

性能

评级:低
分析:尽管某些分层架构的性能表现的确不错,但是这个模式的特点导致它无法带来高性能。因为一次业务请求要穿越所有的架构层,做了很多不必要的工作。

伸缩性

评级:低
分析:由于这种模式以紧密耦合的趋势在发展,规模也比较大,用分层架构构建的程序都比较难以扩展。你可以把各个层分成单独的物理模块或者干脆把整个程序分成多个节点来扩展分层架构,但是总体的关系过于紧密,这样很难扩展。

易开发性

评级:容易
分析:在开发难度上面,分层架构得到了比较高的分数。因为这种架构对大家来说很熟悉,不难实现。大部分公司在开发项目的都是通过层来区分技术的,这种模式对于大多数的商业项目开发来说都很合适。公司的组织架构和他们软件架构之间的联系被戏称为”Conway’s law”。你可以Google一下查查这个有趣的联系。

第二章 事件驱动架构

译者注:文章中 mediator 及 broker 的概念很容易混淆,在文章的结尾处译者对两者的区别(还有 proxy)进行了一定的阐述

事件驱动架构模式是一种主流的异步分发事件架构模式,常用于设计高度可拓展的应用。当然了,它有很高的适应性,使得它在小型应用、大型应用、复杂应用中都能表现得很好。事件驱动架构模式由高度解耦、单一目的的事件处理组件构成,这些组件负责异步接收和处理事件。

事件驱动架构模式包含了两种主要的拓扑结构:**中介(mediator)拓扑结构和代理(broker)**拓扑结构。 mediator 拓扑结构通常在你需要在事件内使用一个核心中介分配、协调多个步骤间的关系、执行顺序时使用;而代理拓扑结构则在你想要不通过一个核心中介将多个事件串联在一起时使用。由于这两种结构在结构特征和实现策略上有很大的差别,所以如果你想要在你的应用中使用它们的话,一定要深入理解两者的技术实现细节,从而为你的实际使用场景选择最合理的结构。

中介 ( Mediator )拓扑结构

中介拓扑结构适合用于拥有多个步骤,并需要在处理事件时能通过某种程度的协调将事件分层的场景,举例来说吧:假设你现在需要进行股票交易,那你首先需要证券所批准你进行交易,然后检查进行这次交易是否违反了股票交易的某种规定,检查完成后将它交给一个经纪人,计算佣金,最后与经纪人确认交易。以上所有步骤都需要通过中介进行某种程度的分配和协调,以决定各个步骤的执行顺序,判断哪些步骤可以并行,哪些步骤可以串行。

在中介拓扑结构中主要有四种组件:事件队列(event queue), 事件中介, 事件通道(event channel), 和 事件处理器(event processor)。当事件流需要被处理,客户端将一个事件发送到某个事件队列中,由消息队列将其运输给事件中介进行处理和分发。事件中介接收到该消息后,并通过将额外的异步事件发送给事件通道,让事件通道执行该异步事件中的每一个步骤,使得事件中介能够对事件进行分配、协调。同时,又因为事件处理器是事件通道的监听器,所以事件通道对异步事件的处理会触发事件处理器的监听事件,使事件处理器能够接收来自事件中介的事件,执行事件中具体的业务逻辑,从而完成对传入事件的处理。事件驱动架构模式中的中介拓扑模式结构大体如下图:

2-1

在事件驱动架构中拥有十几个,甚至几百个事件队列是很常见的情况,该模式并没有对事件队列的实现有明确的要求,这就意味着事件队列可以是消息队列,Web 服务端,或者其它类似的东西。

在事件驱动架构模式中主要有两种事件:初始事件和待处理事件。初始事件是中介所接收到的最原始的事件,没有经过其他组件的处理;而待处理事件是由事件中介生成,由事件处理器接收的组件,不能把待处理事件看作初始事件经过处理后得到的事件,两者是完全不同的概念。

事件中介负责分配、协调初始事件中的各个待执行步骤,事件中介需要为每一个初始事件中的步骤发送一个特定的待处理事件到事件通道中,触发事件处理器接收和处理该待处理事件。这里需要注意的是:事件 中介没有真正参与到对初始事件必须处理的业务逻辑的实现之中;相反,事件中介只是知道初始事件中有哪些步骤需要被处理。

事件中介通过事件通道将与初始事件每一个执行步骤相关联的特定待处理事件传递给事件处理器。尽管我们通常在待处理事件能被多个事件处理器处理时才会在中介拓扑结构中使用 消息主题,但事件通道仍可以是消息队列或 消息主题。(但需要注意的是,尽管在使用 消息主题 时待处理事件能被多个事件处理器处理,但由于接收到的待处理事件各异,所以对其处理的操作也各不相同)

为了能顺利处理待处理事件,事件处理器组件中包含了应用的业务逻辑。此外,事件处理器作为事件驱动架构中的组件,不依赖于其他组件,独立运作,高度解耦,在应用或系统中完成特定的任务。当事件处理器需要处理的事件从细粒度(例如:计算订单的营业税)变为粗粒度(例如:处理一项保险索赔事务),必须要注意的是:一般来说,每一个事件处理器组件都只完成一项唯一的业务工作,并且事件处理器在完成其特定的业务工作时不能依赖其他事件处理器。

虽然事件中介有许多方法可以实现,但作为一名架构工程师,你应该了解所有实现方式,以确保你能为你的实际需求选择了最合适的事件中介。

事件中介最简单、常见的实现就是使用开源框架,例如:Spring Integration,Apache Camel,或 Mule ESB。事件流在这些开源框架中通常用 Java 或 域特定语言(domain-specific language)。在调节过程和业务流程都很复杂的使用场景下,你可以使用业务流程执行语言(BPEL - business process execution language)结合类似开源框架 Apache ODE 的 BPEL 引擎进行开发。BPEL 是一种基于 XML 的服务编制编程语言,它为处理初始事件时需要描述的数据和步骤提供了描述。对每一个拥有复杂业务流程(包括与用户交互的执行步骤)的大型应用来说,你可以使用类似 jBPM 的业务处理管理系统(business process manager)实现事件中介。

如果你需要使用中介拓扑结构,那么理解你的需求,并为其匹配恰当的事件中介实现是构建事件驱动架构过程中至关重要的一环。使用开源框架去解决非常复杂的业务处理、管理、调节事件,注定会失败,因为开源框架只是用 BPM 的方式解决了一些简单的事件分发逻辑,比起你的业务逻辑,其中的事件分发逻辑简直是九牛一毛。

为了解释清楚中介拓扑结构是怎么运作的,我假设你在某家保险公司买了保险,成为了受保人,然后你打算搬家。在这种情况下,初始事件就是重定位事件,或者其他类似的事件。与重定位事件相关的处理步骤就像下图展示的那样,处于事件中介之中。对每一个初始事件的传入,事件中介都会创建一个待处理事件(例如:改变地址,重新计算保险报价,等等……),并将它发送给事件通道,等待发出响应的事件处理器处理待处理事件(例如:客户改变地址的操作流程、报价计算流程,等等……)。直到初始事件中的每一个需要处理的步骤完成了,这项处理才会继续(例如:把所有手续都完成之后,保险公司才会帮你改变地址)。事件中介中,重新报价和更新理赔步骤上面的直线表示这些步骤可以并行处理。

2-1

代理 (Broker) 拓扑结构

代理拓扑结构与中介拓扑结构不同之处在于:代理拓扑结构中没有核心的事件中介;相反,事件流在代理拓扑结构中通过一个轻量的消息代理(例如:ActiveMQ, HornetQ,等等……)将消息串联成链状,分发至事件处理器组件中进行处理。代理扑结构适用的使用场景大致上具有以下特征:你的事件处理流相对来说比较简单,而且你不想(不需要)使用核心的事件分配、调节机制以提高你处理事件的效率。

在代理拓扑结构中主要包括两种组件:代理和事件处理器。代理可被集中或相互关联在一起使用,此外,代理中还可以包含所有事件流中使用的事件通道。

存在于代理组件中的事件通道可以是消息队列,消息主题,或者是两者的组合。

代理拓扑结构大致如下图,如你所见,在这其中没有一个核心的事件中介组件控制和分发初始事件;相反,每一个事件处理器只负责处理一个事件,并向外发送一个事件,以标明其刚刚执行的动作。例如,假设存在一个事件处理器用于平衡证券交易,那么事件处理器可能会接受一个拆分股票的初始事件,为了处理这项初始事件,事件处理器则需要重新平衡股票的投资金额,而这个重新平衡的事件将由另一个事件处理器接收、处理。在这其中有一个细节需要注意:处理初始事件后,由事件处理器发出的事件不被其他事件处理器接收、处理的情况时常会发生,尤其是你在为应用添加功能和进行功能拓展时,这种情况更为常见。

2-3

为了阐明代理拓扑结构的运行机制,我会用一个与讲解中介拓扑结构时类似的例子(受保人旅行的例子)进行解释。因为在代理拓扑结构中没有核心事件中介接收初始事件,那么事件将由客户处理组件直接接收,改变客户的地址,并发出一个事件告知系统客户的地址被其进行了改变(例如:改变地址的事件)。在这个例子中:有两个事件处理器会与改变地址的事件产生关联:报价处理和索赔处理。报价事件处理器将根据受保人的新地址重新计算保险的金额,并发出事件告知系统该受保人的保险金额被其改变。而索赔事件处理器将接受到相同的改变地址事件,不同的是,它将更新保险的赔偿金额,并发出一个更新索赔金额事件告知系统该受保人的赔偿金额被其改变。当这些新的事件被其他事件处理器接收、处理,使事件链一环扣一环地交由系统处理,直到事件链上的所有事件都被处理完,初始事件的处理才算完成。

2-4

如上图所示,代理拓扑结构的设计思想就是将对事件流的处理转换为对事件链的业务功能处理,把代理拓扑结构看作是接力比赛是最好的理解方式:在一场4*100的接力比赛中,每一位运动员都需要拿着一根接力棒跑100米,运动员跑完自己的100米后需要将接力棒传递给下一位运动员,直到最后一位运动员拿着接力棒跑过终点线,整场接力比赛才算结束。根据这样的逻辑我们还可以知道:在代理拓扑结构中,一旦某个事件处理器将事件传递给另一个事件处理器,那么这个事件处理器不会与该事件的后续处理产生任何联系。

顾虑

实现事件驱动架构模式相对于实现其他架构模式会更困难一些,因为它通过异步处理进行事件分发。当你需要在你的应用中使用这种架构模式,你必须处理各种由事件分发处理带来的问题,例如:远程操作功能的可用性,缺少权限,以及在代理或中介中处理事件失败时,用于处理这种情况的重连逻辑。如果你不能很好地解决这些问题,那你的应用一定会出现各种 Bug,让开发团队痛苦不已。

在选择事件驱动架构时还有一点需要注意:在处理单个业务逻辑时,这种架构模式不能处理细粒度的事务。因为事件处理器都高度解耦、并且广泛分布,这使得在这些事件处理器中维持一个业务单元变得非常困难。因此,当你使用这种架构模式架构你的应用时,你必须不断地考虑哪些事件能单独被处理,哪些不能,并为此设计相应事件处理器的处理粒度。如果你发现你需要将一个业务单元切割成许多子单元,并一一匹配相应的事件处理器,那你就要为此进行代码设计;如果你发现你用多个不同的事件处理器处理的哪些业务其实是可以合并到一个业务事件之中的,那么这种模式可能并不适合你的应用,又或者是你的设计出了问题。

使用事件驱动架构模式最困难的地方就在于架构的创建、维护、以及对事件处理器的管理。通常每一个事件都拥有其指定的事件处理协议(例如:传递给事件处理器的数据类型、数据格式),这就使得设下标准的数据格式成为使用事件驱动架构模式中至关重要的一环(例如:XML,JSON,Java 对象,等等……),并在架构创建之初就为这些数据格式授权,以便处理。

模式分析

下面是基于对常见的架构模式特征进行评价的标准,对事件驱动架构模式所作的实际分析,评价是以常见的架构模式的相似实现作为标准进行的,如果你想知道进行对比的其他架构模式对应的特征,可以结尾处查看 附录A 的汇总表。

整体灵活性

评价:高
分析:整体灵活性用于评价架构能否在不断改变的使用场景下快速响应,因为事件处理器组件使用目的单一、高度解耦、与其他事件处理器组件相互独立,不相关联,那么发生的改变对一个或多个事件处理器来说普遍都是独立的,使得对改变的反馈非常迅速,不需要依赖其他事件处理器的响应作出处理。

易于部署

评价:高
分析:总的来看,事件驱动架构模式由于其高度解耦的事件处理器组件的存在,对事件的部署相对来说比较容易,而使用代理拓扑结构比使用中介拓扑结构进行事件调度会更容易一些,主要是因为在 中介拓扑结构中事件处理器与事件中介紧密地耦合在一起:事件处理器中发生改变后,事件中介也随之改变,如果我们需要改变某个被处理的事件,那么我们需要同时调度事件处理器和事件中介。

可测试性

评价:低
分析:虽然在事件驱动架构模式中进行单元测试并不困难,但如果我们要进行单元测试,我们就需要某种特定的测试客户端或者是测试工具产生事件,为单元测试提供初始值。此外,由于事件驱动架构模式是异步进行事件分发的,其异步处理的特性也为单元测试带来了一定的困难。

Performance 性能

评价:高
分析:对消息传递的架构可能会让设计出来的事件驱动架构的表现不如我们的期望,但通常来说,该模式都能通过其异步处理的特性展示优秀的性能表现;换句话来说,高度解耦,异步并行操作大大减少了传递消息过程中带来的时间开销。

伸缩性

评价:高
分析:事件驱动架构中的高度解耦、相互独立的事件处理器组件的存在,使得可拓展性成为该架构与生俱来的优点。架构的这些特定使得事件处理器能够进行细粒度的拓展,使得每一个事件处理器都能单独被拓展,而不影响其他事件处理器。

易于开发

评价:低
分析:由于使用事件驱动架构进行开发需要考虑其异步处理机制、协议创建流程,并且开发者需要用代码为事件处理器和操作失败的代理提供优秀的错误控制环境,无疑使得用事件驱动架构进行开发会比使用其他架构进行开发要困难一些。

译者注

读完整篇文章,我相信大家对 mediator 与 broker 这两个概念有一个大致的印象,但就两者的译文来看,中介和代理似乎没什么区别,尤其是了解 proxy 的读者会更加困惑,这三者之间到底是什么关系?它们的概念是互通的吗?为了解决这种混淆,译者将在此阐述三者间的区别:

假如现在我有一个事件/事件流需要被处理,那么使用 mediator、broker、proxy 处理事件的区别在哪里呢?

  • 如果我们使用 mediator,那就意味着我将把事件流交给 mediator,mediator 会帮我把事件分解为多个步骤,并分析其中的执行逻辑,调整和分发事件(例如判断哪些事件可以并行,哪些事件可以串行),然后根据 mediator 分解、调节的结果去执行事件中的每一个步骤,把所有步骤完成后,就能把需要处理的事件处理好。
  • 如果我们使用 broker,那就意味着我将把事件交给 broker,broker 获得事件后会把事件发出去(在本文中为:通知架构中所有可用的事件处理器),事件处理器们接收到事件以后,判断处理这个事件是否为自己的职责之一,如果不是则无视,与自己有关则把需要完成的工作完成,完成后如果事件还有后续需要处理的事件,则通过 broker 再次发布,再由相关的事件处理器接收、处理。以这样的方式将事件不断分解,沿着事件链一级一级地向下处理子事件,直到事件链中的所有事件被完成,我的事件也就处理好了。
  • 如果我们使用 proxy,那就意味着我自己对需要处理的事件进行了分解,然后把不同的子事件一一委托给不同的 proxy,由被委托的 proxy 帮我完成子事件,从而完成我要做的事件。

第三章 微内核架构

微内核架构模式(也称为插件化应用架构)对于基于产品的应用程序来说是一个很自然的选择。基于产品的应用是指一个经过打包的、可以通过版本下载的一个典型的第三方产品。然而,很多公司也会开发和发布他们的内部商业软件,完整的版本号、发布日志和可插拔的新特性,这些就非常符合微内核架构的思想。微内核架构模式可以通过插件的形式添加额外的特性到核心系统中,这提供了很好的扩展性,也使得新特性与核心系统隔离开来。( 译者注: 比如,著名的Eclipse IDE就是基于插件化开发的,eclipse核心更像是一个微内核,或者我们可把它叫做开放平台,其他的功能通过安装插件的形式添加到eclipse中。 )

模式描述

微内核架构主要需要考虑两个方面: 核心系统和插件模块。应用逻辑被划分为独立的插件模块和核心系统,这样就提供良好的可扩展性、灵活性,应用的新特性和自定义处理逻辑也会被隔离。图3-1演示了基本的微内核架构。

微内核架构的核心系统一般情况下只包含一个能够使系统运作起来的最小化模块。很多操作系统的实现就是使用微内核架构,因此这也是该架构名字的由来。从商业应用的角度看,核心系统通常是为特定的使用场景、规则、或者复杂条件处理定义了通用的业务逻辑,而插件模块根据这些规则实现了具体的业务逻辑。

3-1

插件模块是一个包含专业处理、额外特性的独立组件,自定义代码意味着增加或者扩展核心系统以达到产生附加的业务逻辑的能力。通常,插件模块之间应该是没有任何依赖性的,但是你也可以设计一个需要依赖另一个插件的插件。但无论如何,使得插件之间可以通信的同时避免插件之间产生依赖又是一个特别重要的问题。

核心系统需要了解插件模块的可用性以及如何获取到它们。一个通用的实现方法是通过一组插件注册表。这个插件注册表含有每个插件模块的信息,包括它的名字、数据规约和远程访问协议(取决于插件如何与核心系统建立连接)。例如,一个税务软件的用于标识高风险的税务审计插件可能会有一个含有插件名(比如AuditChecker)的注册入口,数据规约(输入数据、输出数据)和规约格式( 比如xml )。如果这个插件是通过SOAP服务访问,那么它可能会包含一个WSDL (Web Services Definition Language).

插件模块可以通过多种方式连接到核心系统,包括OSGi ( open service gateway initiative )、消息机制、web服务或者直接点对点的绑定 ( 比如对象实例化,即依赖注入 )。你使用的连接类型取决于你构建的应用类型和你的特殊需求(比如单机部署还是分布式部署)。微内核架构本身没有指定任何的实现方式,唯一的规定就是插件模块之间不要产生依赖。

插件和核心系统的通信规范包含标准规范和自定义规范。自定义规范典型的使用场景是插件组件是被第三方构建的。在这种情况下,通常是在第三方插件规约和你的标准规范创建一个Adapter来使核心系统根本不需要知道每个插件的具体细节。当创建标准规范 ( 通常是通过XML或者Java Map )时,从一开始就创建一个版本策略是非常重要的。

架构示例

也许微内核架构的最好示例就是大家熟知的Eclipse IDE了。下载最基本的Eclipse后,它只能提供一个编辑器。然后,一旦你开始添加插件,它就变成一个高度可定制化和非常有用的产品(译者注 : 更多内容大家可以参考 开源软件架构 卷1:第6章 Eclipse之一 )。浏览器是另一个使用微内核架构的产品示例,它由一个查看器和其他扩展的插件组成。

基于微内核架构的示例数不胜数,但是大型的商业应用呢?微内核应用架构也适用于这些情形。为了阐述这个观点,让我们来看看另一个保险公司的示例,但是这次的示例会涉及保险赔偿处理。

赔偿处理是一个非常复杂的过程。每个州都有不同的关于保险赔偿的规则和条文。例如一些州允许在你的挡风玻璃被石头砸碎时免费进行替换,但是一些州则不是这样。因为大家的标准都不一样,因此赔偿标准几乎可以是无限的。

有很多保险赔偿应用运用大型和复杂的规则处理引擎来处理不同规则带来的复杂性。然而,可能会因为某条规则的改变而引起其他规则的改变而使得这些规则处理引擎变成一个大泥球,或者使简单需求变更会需要一个很大的分析师、工程师、测试工程师来进行处理。使用微内核架构能够很好的解决这个问题,核心系统只知道根据赔偿规则处理,但这个赔偿规则是抽象的,系统将赔偿规则作为一个插件规范,具体的规则有对应的实现,然后注入到系统中即可。

图3-2中的一堆文件夹代表了赔偿处理核心系统。它包含一些处理保险赔偿的基本业务逻辑。每一个插件模块包含每个州的具体赔偿规则。在这个例子中,插件模块通过自定义源代码实现或者分离规则引起实例。不管具体实现如何,关键就在于赔偿规则和处理都从核心系统中分离,而这些规则和处理过程都可以被动态地添加、移除,而这些改变对于核心系统和其他插件只有很小的影响或者根本不产生影响。

3-2

注意事项

对于微内核架构来说一个很重要的一点就是它能够被嵌入或者说作为另一种架构的一部分。例如,如果这个架构解决的是一个你应用中易变领域的特定的问题 ( 译者注 : 即插件化能够解决你应用中的某个特定模块的架构问题 ),你可能会发现你不能在整个应用中使用这种架构。在这种情况下,你可以将微内核架构嵌入到另一个架构模式中 ( 比如分层架构 )。同样的,在上一章节中描述的事件驱动架构中的事件处理器组件也可以使用微内核架构。

微内核架构对渐进式设计和增量开发提供了非常好的支持。你可以先构建一个单纯的核心系统,随着应用的演进,系统会逐渐添加越来越多的特性和功能,而这并不会引起核心系统的重大变化。

对基于产品的应用来说,微内核架构应该是你的第一选择。特别是那些你会在后续开发中发布附加特性和控制哪些用户能够获取哪些特性的应用。如果你在后续开发中发现这个架构不能满足你的需求了,你能够根据你的特殊需求将你的应用重构为另一个更好的架构。

模式分析

下面的表格中包含了微内核架构每个特性的评级和分析。以微内核架构的最经典的实现方式的自然趋势为依据对每个特性进行评级。关于微内核架构与其他模式的相关性比较请参考附录A。

整体灵活性

评级 : 高
分析 : 整体灵活性是指能够快速适应不断变化的环境的能力。通过插件模块的松耦合实现,可以将变化隔离起来,并且快速满足需求。通常,微内核架构的核心系统很快趋于稳定,这样系统就变得很健壮,随着时间的推移它也不会发生多大改变。

易于部署

评级 : 高
分析 : 根据实现方式,插件模块能够在运行时被动态地添加到核心系统中 ( 比如,热部署 ),把停机时间减到最小。

可测试性

评级 : 高
分析 : 插件模块能够被独立的测试,能够非常简单地被核心系统模拟出来进行演示,或者在对核心系统很小影响甚至没有影响的情况下对一个特定的特性进行原型展示。

性能

评级 : 高
分析 : 使用微内核架构不会自然而然地使你的应用变得高性能。通常,很多使用微内核架构的应用运行得很好,因为你能定制和简化应用程序,使它只包含那些你需要的功能模块。JBoss应用服务器就是这方面的优秀示例: 依赖于它的插件化架构,你可以只加载你需要的功能模块,移除那些消耗资源但没有使用的功能特性,比如远程访问,消息传递,消耗内存、CPU的缓存,以及线程,从而减小应用服务器的资源消耗。

伸缩性

评级 : 低
分析 : 因为微内核架构的实现是基于产品的,它通常都比较小。它们以独立单元的形式实现,因此没有太高的伸缩性。此时,伸缩性就取决于你的插件模块,有时你可以在插件级别上提供可伸缩性,但是总的来说这个架构并不是以构建高度伸缩性的应用而著称的。

易于开发

评级 : 低
分析 : 微内核架构需要考虑设计和规约管理,使它不会很难实现。规约的版本控制,内部的插件注册,插件粒度,丰富的插件连接的方式等是涉及到这个架构模式实现复杂度的重要因素。

第四章 微服务架构

微服务架构模式作为替代单体应用和面向服务架构的一个可行的选择,在业内迅速取得进展。由于这个架构模式仍然在不断的发展中,在业界存在很多困惑——这种模式是关于什么的?它是如何实现的?本报告的这部分将为你提供关键概念和必要的基础知识来理解这一重要架构模式的好处(和取舍),以此来判断这种架构是否适合你的应用。

模式描述

不管你选择哪种拓扑或实现风格,有几种常见的核心概念适用于一般架构模式。第一个概念是单独部署单元。如图4-1所示,微服务架构的每个组件都作为一个独立单元进行部署,让每个单元可以通过有效、简化的传输管道进行通信,同时它还有很强的扩展性,应用和组件之间高度解耦,使得部署更为简单。

也许要理解这种模式,最重要的概念就是服务组件(service component)。不要考虑微服务架构内部的服务,而最好是考虑服务组件,从粒度上讲它可以小到单一的模块,或者大至一个应用程序。服务组件包含一个或多个模块(如Java类),这些模块可以提供一个单一功能(如,为特定的城市或城镇提供天气情况),或也可以作为一个大型商业应用的一个独立部分(如,股票交易布局或测定汽车保险的费率)。在微服务架构中,正确设计服务组件的粒度是一个很大的挑战。在接下来的服务组件部分对这一挑战进行了详细的讨论。

4-1

微服务架构模式的另一个关键概念是它是一个分布式的架构,这意味着架构内部的所有组件之间是完全解耦的,并通过某种远程访问协议(如, JMS, AMQP, REST, SOAP, RMI等)进行访问。这种架构的分布式特性是它实现一些优越的可扩展性和部署特性的关键所在。

微服务架构另一个令人兴奋的特性是它是由其他常见架构模式存在的问题演化来的,而不是作为一个解决方案被创造出来等待问题出现。微服务架构的演化有两个主要来源:使用分层架构模式的单体应用和使用面向服务架构的分布式应用。

由单体应用( 一个应用就是一个整体 )到微服务的发展过程主要是由持续交付开发促成的。从开发到生产的持续部署管道概念,简化了应用程序的部署。单体应用通常是由紧耦合的组件组成,这些组件同时又是另一个单一可部署单元的一部分,这使得它繁琐,难以改变、测试和部署应用(因此常见的“月度部署”周期出现并通常发生在大型IT商店项目)。这些因素通常会导致应用变得脆弱以至于每次有一点新功能部署后应用就不能运行。微服务架构模式通过将应用分隔成多个可部署的单元(服务组件)的方法来解决这一问题,这些服务组件可以独立于其他服务组件进行单独开发、测试和部署。

另一个导致微服务架构模式产生的演化过程是由面向服务架构模式(SOA)应用程序存在的问题引起的。虽然SOA模式非常强大,提供了无与伦比的抽象级别、异构连接、服务编排,并保证通过IT能力调整业务目标,但它仍然是复杂的,昂贵的,普遍存在,它很难理解和实现,对大多数应用程序来说过犹不及。微服务架构通过简化服务概念,消除编排需求、简化服务组件连接和访问来解决复杂度问题。

模式拓扑

虽然有很多方法来实现微服务架构模式,但三个主要的拓扑结构脱颖而出,最常见和流行的有:基于REST API的拓扑结构,基于REST的应用拓扑结构和集中式消息拓扑结构。

基于REST的API拓扑适用于网站,通过某些API对外提供小型的、自包含的服务。这种拓扑结构,如图4 - 2所示,由粒度非常细的服务组件(因此得名微服务)组成,这些服务组件包含一个或两个模块并独立于其他服务来执行特定业务功能。在这种拓结构扑中,这些细粒度的服务组件通常被REST-based的接口访问,而这个接口是通过一个单独部署的web API层实现的。此种拓扑的例子包含一些常见的专用的、基于云的RESTful web service,大型网站像Yahoo, Google, and Amazon都在使用。

4-2

基于REST的应用拓扑结构与基于REST API的不同,它通过传统的基于web的或胖客户端业务应用来接收客户端请求,而不是通过一个简单的API层。如图4-3所示,应用的用户接口层(user interface layer)是一个web应用,可以通过简单的REST-based接口访问单独部署的服务组件(业务功能)。该拓扑结构中的服务组件与API-REST-based拓扑结构中的不同,这些服务组件往往会更大、粒度更粗、代表整个业务应用程序的一小部分,而不是细粒度的、单一操作的服务。这种拓扑结构常见于中小型企业等复程度相对较低的应用程序。

4-3

微服务架构模式中另一个常见的方法是集中式消息拓扑。该拓扑(如图4-4所示)与前面提到的基于REST的应用拓扑类似,不同的是,application REST- based拓扑结构使用REST进行远程访问,而该拓扑结构则使用一个轻量级的集中式消息代理(如,ActiveMQ, HornetQ等等)。不要将该拓扑与面向服务架构模式混淆或将其当做SOA简化版(“SOA-Lite”),这点是极其重要的。该拓扑中的轻量级消息代理(Lightweight Message Broker)不执行任何编排,转换,或复杂的路由;相反,它只是一个轻量级访问远程服务组件的传输工具。

集中式消息拓扑结构通常应用在较大的业务应用程序中,或对于某些对传输层到用户接口层或者到服务组件层有较复杂的控制逻辑的应用程序中。该拓扑较之先前讨论的简单基于REST的拓扑结构,其好处是有先进的排队机制、异步消息传递、监控、错误处理和更好的负载均衡和可扩展性。与集中式代理相关的单点故障和架构瓶颈问题已通过代理集群和代理联盟(将一个代理实例为分多个代理实例,把基于系统功能区域的吞吐量负载划分开处理)解决。

4-4

避免依赖和编排

微服务架构模式的主要挑战之一就是决定服务组件的粒度级别。如果服务组件粒度过粗,那你可能不会意识到这个架构模式带来的好处(部署、可扩展性、可测试性和松耦合),然而,服务组件粒度过细将导致服务编制要求,这会很快导致将微服务架构模式变成一个复杂、容易混淆、代价昂贵并易于出错的重量级面向服务架构。

如果你发现需要从应用内部的用户接口或API层编排服务组件,那么很有可能你服务组件的粒度太细了。如果你发现你需要在服务组件之间执行服务间通信来处理单个请求,那么很有可能要么是你服务组件的粒度太细了,要么是没有从业务功能角度正确划分服务组件。

服务间通信,可能导致组件之间产生耦合,但可以通过共享数据库进行处理。例如,若一个服务组件处理网络订单而需要用户信息时,它可以去数据库检索必要的数据,而不是调用客户服务组件的功能。

共享数据库可以处理信息需求,但是共享功能呢?如果一个服务组件需要的功能包含在另一个服务组件内,或是一个公共的功能,那么有时你可以将服务组件的共享功能复制一份(因此违反了DRY规则:don’t repeat yourself)。为了保持服务组件独立和部署分离,微服务架构模式实现中会存在一小部分由重复的业务逻辑而造成的冗余,这在大多数业务应用程序中是一个相当常见的问题。小工具类可能属于这一类重复的代码。

如果你发现就算不考虑服务组件粒度的级别,你仍不能避免服务组件编排,这是一个好迹象,可能此架构模式不适用于你的应用。由于这种模式的分布式特性,很难维护服务组件之间的单一工作事务单元。这种做法需要某种事务补偿框架回滚事务,这对此相对简单而优雅的架构模式来说,显著增加了复杂性。

注意事项

微服务架构模式解决了很多单体应用和面向服务架构应用存在的问题。由于主要应用组件被分成更小的,单独部署单元,使用微服务架构模式构建的应用程序通常更健壮,并提供更好的可扩展性,支持持续交付也更容易。

该模式的另一个优点是,它提供了实时生产部署能力,从而大大减少了传统的月度或周末“大爆炸”生产部署的需求。因为变化通常被隔离成特定的服务组件,只有变化的服务组件才需要部署。如果你的服务组件只有一个实例,你可以在用户界面程序编写专门的代码用于检测一个活跃的热部署,一旦检测到就将用户重定向到一个错误页面或等待页面。你也可以在实时部署期间,将服务组件的多个实例进行交换,允许应用程序在部署期间保持持续可用性(分层架构模式很难做到这点)。

最后一个要重视的考虑是,由于微服务架构模式是分布式的架构,他与事件驱动架构模式具有一些共同的复杂的问题,包括约定的创建、维护,和管理,远程系统的可用性,远程访问身份验证和授权。

模式分析

下面这个表中包含了微服务架构模式的特点分析和评级,每个特性的评级是基于自然趋势,基于典型模式实现的能力特性,以及该模式是以什么闻名的。本报告中该模式与其他模式的并排比较,请参考报告最后的附件A。

整体灵活性

评级:高
分析:整体的灵活性是能够快速响应不断变化的环境。由于单独部署单元的概念,变化通常被隔离成单独的服务组件,使得部署变得快而简单。同时,使用这种模式构建的应用往往是松耦合的,也有助于促进改变。

易于部署

评级:高
分析:整体来讲,由于该模式的解耦特性和事件处理组件使得部署变得相对简单。broker拓扑往往比mediator拓扑更易于部署,主要是因为event-mediator组件与事件处理器是紧耦合的,事件处理器组件有一个变化可能导致event mediator跟着变化,有任何变化两者都需要部署。

可测试性

评级:高
分析:由于业务功能被分离成独立的应用模块,可以在局部范围内进行测试,这样测试工作就更有针对性。对一个特定的服务组件进行回归测试比对整个单体应用程序进行回归测试更简单、更可行。而且,由于这种模式的服务组件是松散耦合的,从开发角度来看,由一个变化导致应用其他部分也跟着变化的几率很小,并能减小由于一个微小的变化而不得不对整个应用程序进行测试的负担。

性能

评级:低
分析:虽然你可以从实现该模式来创建应用程序并可以很好的运行,整体来说,由于微服务架构模式的分布式特性,并不适用于高性能的应用程序。

伸缩性

评级:高
分析:由于应用程序被分为单独的部署单元,每个服务组件可以单独扩展,并允许对应用程序进行扩展调整。例如,股票交易的管理员功能区域可能不需要扩展,因为使用该功能的用户很少,但是交易布局服务组件可能需要扩展,因为大多数交易应用程序需要具备处理高吞吐量的功能。

易于开发

评级:高
分析:由于功能被分隔成不同的服务组件,由于开发范围更小且被隔离,开发变得更简单。程序员在一个服务组件做出一个变化影响其他服务组件的几率是很小的,从而减少开发人员或开发团队之间的协调。

第五章 基于空间的架构

大多数基于网站的商务应用都遵循相同的请求流程:一个请求从浏览器发到web服务器,然后到应用服务器,然后到数据库服务器。虽然这个模式在用户数不大的时候工作良好,但随着用户负载的增加,瓶颈会开始出现,首先出现在web服务器层,然后应用服务器层,最后数据库服务器层。通常的解决办法就是向外扩展,也就是增加服务器数量。这个方法相对来说简单和廉价,并能够解决问题。然而,对于大多数高访问量的情况,它只不过是把web服务器的问题移到了应用服务器。而扩展应用服务器会更复杂,而且成本更高,并且又只是把问题移动到了数据库服务器,那会更复杂,更贵。就算你能扩展数据库服务器,你最终会陷入一个金字塔式的情形,在金字塔最下面是web服务器,它会出现最多的问题,但也最好伸缩。金字塔顶部是数据库服务器,问题不多,但最难伸缩。

在一个高并发大容量的应用中,数据库通常是决定应用能够支持多少用户同时在线的关键因素。虽然各种缓存技术和数据库伸缩产品都在帮助解决这个问题,但数据库难以伸缩的现实并没有改变。

基于空间的架构模型是专门为了解决伸缩性和并发问题而设计的。它对于用户数量不可预测且数量级经常变化的情况同样适用。在架构级别来解决这个伸缩性问题通常是比增加服务器数量或者提高缓存技术更好的解决办法。

模型介绍

基于空间的模型(有时也称为云架构模型)旨在减少限制应用伸缩的因素。模型的名字来源于分布式共享内存中的 tuple space(数组空间)概念。高伸缩性是通过去除中心数据库的限制,并使用从内存中复制的数据框架来获得的。保存在内存的应用数据被复制给所有运行的进程。进程可以动态的随着用户数量增减而启动或结束,以此来解决伸缩性问题。这样因为没有了中心数据库,数据库瓶颈就此解决,此后可以近乎无限制的扩展了。

大多数使用这个模型的应用都是标准的网站,它们接受来自浏览器的请求并进行相关操作。竞价拍卖网站是一个很好的例子 ( 12306更是一个典型的示例 )。网站不停的接受来自浏览器的报价。应用收到对某一商品的报价,记录下报价和时间,并且更新对该商品的报价,将信息返回给浏览器。

这个架构中有两个主要的模块:处理单元虚拟化中间件。下图展示了这个架构和里面的主要模块。

5-1

处理单元包含了应用模块(或者部分的应用模块)。具体来说就是包含了web组件以及后台业务逻辑。处理单元的内容根据应用的类型而异——小型的web应用可能会部署到单一的处理单元,而大型一些的应用会将应用的不同功能模块部署到不同的处理单元中。典型的处理单元包括应用模块,以及保存在内存的数据框架和为应用失败时准备的异步数据持久化模块。它还包括复制引擎,使得虚拟化中间件可以将处理单元修改的数据复制到其他活动的处理单元。

虚拟化中间件负责保护自身以及通信。它包含用于数据同步和处理请求的模块,以及通信框架,数据框架,处理框架和部署管理器。这些在下文中即将介绍的部分,可以自定义编写或者购买第三方产品来实现。

组件间合作

基于空间的架构的魔力就在虚拟化中间件,以及各个处理单元中的内存中数据框架。下图展示了包含着应用模块、内存中数据框架、处理异步数据恢复的组件和复制引擎的处理单元架构。

虚拟化中间件本质上是架构的控制器,它管理请求,会话,数据复制,分布式的请求处理和处理单元的部署。虚拟化中间件有四个架构组件:通信框架,数据框架,处理框架和部署管理器。

5-2

通信框架

通信框架管理输入请求和会话信息。当有请求进入虚拟化中间件,通信框架就决定有哪个处理单元可用,并将请求传递给这个处理单元。通信框架的复杂程度可以从简单的round robin算法到更复杂的用于监控哪个请求正在被哪个处理单元处理的next-available算法。

5-3

数据框架

数据框架可能是这个架构中最重要和关键的组件。它与各个处理单元的数据复制引擎交互,在数据更新时来管理数据复制功能。由于通信框架可以将请求传递给任何可用的处理单元,所以每个处理单元包含完全一样的内存中数据就很关键。下图展示处理单元间如何同步数据复制,实际中是通过非常迅速的并行的异步复制来完成的,通常在微秒级。

5-4

处理框架

处理框架,就像下图所示,是虚拟化中间件中一个可选组件,负责管理在有多个处理单元时的分布式请求处理,每个处理单元可能只负责应用中的某个特定功能。如果请求需要处理单元间合作(比如,一个订单处理单元和顾客处理单元),此时处理框架就充当处理单元见数据传递的媒介。

5-5

部署管理器

部署管理器根据负载情况管理处理单元的动态启动和关闭。它持续检测请求所需时间和在线用户量,在负载增加时启动新的处理单元,在负载下降时关闭处理单元。它是实现可变伸缩性需求的关键。

其他考虑

基于空间的架构是一个复杂和实现起来相对昂贵的框架。对于有可变伸缩性需求的小型web应用是很好的选择,然而,对于拥有大量数据操作的传统大规模关系型数据库应用,并不那么适用。

虽然基于空间的架构模型不需要集中式的数据储存,但通常还是需要这样一个,来进行初始化内存中数据框架,和异步的更新各处理单元的数据。通常也会创建一个单独的分区,来从隔离常用的断电就消失的数据和不常用的数据,这样减少处理单元之间对对方内存数据的依赖。

值得注意的是,虽然这个架构的另一个名字是云架构,处理单元(以及虚拟化中间件)都没有放在云端服务或者PaaS上。他们同样可以简单的放在本地服务器,这也是为什么我更倾向叫它“基于空间的架构”。

从产品实现的角度讲,这个架构中的很多组件都可以从第三方获得,比如GemFire, JavaSpaces, GigaSpaces,IBM Object Grid,nCache,和 Oracle Coherence。由于架构的实现根据工程的预算和需求而异,所以作为架构师,你应该在实现或选购第三方产品前首先明确你的目标和需求。

架构分析

下面的表格是这个架构的特征分析和评分。每个特征的评分是基于一个典型的架构实现来给出的。要知道这个模式相对别的模式的对比,请参见最后的附录A。

综合能力

评分:高
分析:综合能力是对环境变化做出快速反应的能力。因为处理单元(应用的部署实例)可以快速的启动和关闭,整个应用可以根据用户量和负载做出反应。使用这个架构通常在应对代码变化上,由于较小的应用规模和组件间相互依赖,也会反映良好。

易于部署

评分:高
分析:虽然基于空间的架构通常没有解耦合并且功能分布,但他们是动态的,也是成熟的基于云的工具,允许应用轻松的部署到服务器。

可测试性

评分:低
分析:测试高用户负载既昂贵又耗时,所以在测试架构的可伸缩性方面很困难

性能

评分:高
分析:通过内存中数据存取和架构中的缓存机制可获得高性能

伸缩性

评分:高
分析:高伸缩性是源于几乎不依赖集中式的数据库,从而去除了这个限制伸缩性的瓶颈。

易于开发

评分:低
分析:主要是因为难以熟悉这个架构开发所需得工具和第三方产品,因此使用该架构需要较大的学习成本。而且,开发过程中还需要特别注意不要影响到性能和可伸缩性。

附录A

模式分析总结

图A-1 总结了在这个报告中,对于架构模式的每部分进行的模式分析所产生的影响。这个总结帮助你确定哪些模式可能是最适合你的情况。例如,如果你的架构模式重点是可伸缩性,你可以在这个图表看看事件驱动模式,microservices模式,和基于空间模式,这些对于你来说可能是很好的架构模式的选择。同样的,如果你的程序注重的是分层架构模式,你可以参考图看到部署、性能和可伸缩性的在你的架构中所存在的风险。

a-1

同时这个图表将指导你选择正确的模式,因为在选择一种架构模式的时候,有更多的因素需要考虑。你必须分析你的环境的各个方面,包括基础设施的支持,开发人员技能,项目预算,项目最后期限,和应用程序大小等等。选择正确的架构模式是至关重要的,因为一旦一个架构被确定就很难改变。

原文

软件架构模式

一. 几个重要概念

在学习 Dart 语言时, 先要清楚以下几个事实和概念:

  • Dart 语句是以分号 ; 结尾的。
  • 任何保存在变量中的都是一个对象,并且所有的对象都是对应一个类的实例。无论是数字,函数和 null 都是对象。所有对象继承自 Object 类。
  • 尽管 Dart 是强类型的,但是 Dart 可以推断类型,所以类型注释是可选的。 如果要明确说明不需要任何类型, 需要使用特殊类型 dynamic 。
  • Dart 支持泛型,如 List <int>(整数列表)或 List <dynamic>(任何类型的对象列表)。
  • Dart 支持顶级函数,例如 main() ,同样函数绑定在类或对象上(分别是静态函数和实例函数)。以及支持函数内创建函数( 嵌套或局部函数)。
  • Dart 支持顶级变量,同样变量绑定在类或对象上(静态变量和实例变量)。实例变量有时称为字段或属性。
  • 与 Java 不同,Dart 没有关键字 publicprotectedprivate 。如果标识符以下划线 _ 开头,则它相对于库是私有的。
  • 标识符以字母或下划线 _ 开头,后跟任意字母和数字组合。

二. Dart关键字

Dart 语言关键字列表如下:

else import static assert enum
in uper async export interface
switch await extends is sync
break external library this case
factory mixin throw catch false
new true class final null
try const finally on typedef
continue for operator var covariant
Function part void default get
rethrow while deferred hide return
with do if set yield

使用 Dart 时应避免使用这些单词作为标识符。

三. 入口函数

Dart 有两种入口主函数:

1
2
3
4
5
6
7
8
 main(){
print('Hello Dart!');
}

// 加 void 表示没有返回值
void main(){
print('Hello Flutter!');
}

四. 代码注释

Dart 支持单行注释、多行注释和文档注释:

  1. 单行注释:单行注释以 // 开始。 所有在 // 和改行结尾之间的内容被编译器忽略。
  2. 多行注释:多行注释以 /* 开始, 以 */ 结尾。 所有在 /**/ 之间的内容被编译器忽略 (不会忽略文档注释)。 多行注释可以嵌套。
  3. 文档注释:文档注释可以是多行注释,也可以是单行注释,文档注释以 /// 或者 /** 开始。 在连续行上使用 /// 与多行文档注释具有相同的效果。
1
2
3
4
5
6
7
8
9
10
// 单行注释
/*
* 多行注释
*/

/// 文档注释

/**
* 文档注释
*/三、变量

五. 变量

1. 定义变量

Dart 中定义变量有两种方式,一种是静态类型语言常用的方式,即指定变量类型;另一种则是动态语言的常用方式,即不指定类型,由 Dart 自动类型判断,一般最好指定类型。

// 指定变量类型:

1
2
3
4
String name = 'ImportV';

// 使用关键字 var ,不指定变量类型:
var name = 'ImportV';

未初始化的变量默认值是 null。即使变量是数字类型默认值也是 null,因为在 Dart 中一切都是对象,数字类型也不例外。

1
2
int a;
print(a); // 输出值为 null

\2. 动态改变变量类型

如想动态改变变量的数据类型,应当使用 dynamicObject 来定义变量:

1
2
3
dynamic a = 'Dart';  // 也可使用 Object a = 'Dart';
a = 2021;
print(a); // 输出结果为 2021

\3. 定义常量

Dart 中定义常量也有两种方式,一种使用 final 关键字,另一种是使用 const 关键字。需要注意的是, final 定义的常量是运行时常量,而 const 常量则是编译时常量,也就是说 final 定义常量时,其值可以是一个变量,而 const 定义的常量,其值必须是一个字面常量值。

1
2
3
4
5
6
7
final a = DateTime.now();
print(a); // 输出现在的时间

const a = DateTime.now();
print(a); // 报错

六. 内建类型

Dart 语言支持以下内建类型:

img
\1. Number

1
2
3
4
5
6
// int 必须是整型:
int a = 123;

// 从 Dart 2.0 开始,double 既可以是浮点数也可以是整型:
double b = 12.34;
double c = 6; // 相当于 double c = 6.0

\2. String

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 用单引号或者双引号定义字符串:
String s1 = 'Hello Dart!'
String s2 = "Hello Flutter!"

// 当字符串有引号时:
String s3 = 'I\'m ImportV!'
string s4 = "I'm ImportV!"

// 使用连续三个单引号或者三个双引号实现多行字符串对象的创建:
String s5 = '''
Hello Dart!
Hello Flutter!
Hello ImportV!
''';
// 使用 r 前缀,可以创建 “原始 raw” 字符串:
String s6 = r'Hello Dart \n Hello Flutter!'

// String -> int:
int a = int.parse('123');

// String -> double:
double b = double.parse('12.34');

// int -> String:
String str1 = 123.toString();

// double -> String:
String str2 = 12.3456.toStringAsFixed(2); // 括号内为要保留的小数位数

// 字符串拼接,用“+”拼接,字面量字符串也可以直接写在一起:
String s1 = 'Hello Dart! ' 'Hello Flutter! ' 'Hello ImportV! ';
String s2 = 'Hello Dart! ' + 'Hello Flutter! ' + 'Hello ImportV! ';

// 字符串可以通过 ${expression} 的方式内嵌表达式。 如果表达式是一个标识符,则 {} 可以省略:
String s1 = 'Hello Flutter!';
String s2 = 'Hello Dart! $s1 Hello ImportV!';

\3. Boolean

1
2
// 布尔类型默认值为null,字面量只有 true 和 false ,这两个对象都是编译时常量:
bool b = true;

\4. List

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 定义列表方法一:
List lt1 = [1, 2, 3, 4];

// 定义列表方法二:
List a = [];
a.add(1);
a.add(2);
a.add(3);

// 获取列表属性:
List a = ['Dart', 'Flutter', 'ImportV'];
print(a.length); // 获取列表长度
print(a.isEmpty); // 判断列表是否为空,如果是,输出 true
print(a.isNotEmpty); // 判断列表是否为空,如果不是,输出 true
print(a.reversed); // 将列表逆序输出(翻转列表)

// 列表的下标索引是从0开始的:
List a = [1, 2, 3, 4];
print(a[0]);

// 拼接与插入:
List a = ['Dart', 'Flutter'];
a.add('Android'); // 拼接一个元素
a.addAll(['Android', 'iOS']); // 拼接多个元素
a.insert(2, 'Android'); // 插入一个元素,2 为插入位置的索引值
a.insertAll(2, ['Android', 'iOS']); // 插入多个元素

// 列表转字符串:
List a = ['Dart', 'Flutter', 'Android'];
String b = a.join('->'); // 此处 b 的字面量为 Dart->Flutter->Android

// 字符串转列表:
String a = 'Dart->Flutter->Android';
List b = a.split('->'); // 此处 b 的字面量为 [Dart, Flutter, Android]

// 指定列表类型:
List a = <String>[];
a.add('Flutter');
a.add(1); //报错

// 在列表字面量前添加 const 关键字,定义一个不可改变的列表(编译时常量):
List a = const [1, 2, 3, 4];
a[0] = 2; // 报错

\5. Set

Set 是没有顺序且元素不能重复的集合,因此不能通过索引去获取值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 定义集合方法一:
Set a = {'Dart', 'Flutter', 'ImportV'};

// 定义集合方法二
Set a = {};
a.add('Dart');
a.add('Flutter');
a.add('ImportV');

// 创建空集合:
Set a = {}; // 方法一
var a = <String>{}; // 方法二
var a = {}; // 这样会创建一个空的 Map,而不是 Set

// 获取集合中元素的个数:
Set a = {'Dart', 'Flutter', 'ImportV'};
print(a.length); // 输出为 3

// 创建一个不可改变的集合(编译时常量):
Set a = const {'Dart', 'Flutter', 'ImportV'};
a.add('Android'); // 报错

// 列表转集合:
List a = ['Dart', 'Flutter', 'Android'];
print(a.toSet());

// 集合转列表:
Set a = {'Dart', 'Flutter', 'Android'};
print(a.toList());

\6. Map

Map(映射)是用来关联 keys 和 values 的对象。 keys 和 values 可以是任何类型的对象。在一个 Map 对象中一个 key 只能出现一次。 但是 value 可以出现多次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 定义映射方法一:
Map a = {
// key value
'first': 'Dart',
'second': 'Flutter',
'third': 'Android'
};

// 定义映射方法二:
Map a = {};
a['first'] = 'Dart';
a['second'] = 'Flutter';
a['third'] = 'Android';

// 获取映射长度:
print(a.length);

// 获取指定 value 值:
print(a['first']);

// 在映射字面量前添加 const 关键字,定义一个不可改变的映射(编译时常量):
Map a = const {'first': 'Dart', 'second': 'Flutter', 'third': 'Android'};
a['third'] = 'iOS' //报错五、运算符

七. 运算符

下面是 Dart 定义的运算符:

1. 算数运算符

除了基本的算术运算外,Dart 还支持前缀和后缀、自增和自减运算符:

1
2
3
4
5
6
7
var a, b;
a = 0;

b = ++a; // a自加1后赋值给b
b = a++; // a赋值给b后自加1
b = --a; // a自减1后赋值给b
b = a--; // a赋值给b后自减1

2. 类型判断运算符

使用 as 运算符将对象强制转换为特定类型时,通常可以认为是 is 类型判定后,被判定对象调用函数的一种缩写形式。 请考虑以下代码:

1
2
3
if (emp is Person) {
emp.firstName = 'Bob';
}

使用 as 运算符进行缩写:

1
(emp as Person).firstName = 'Bob';

【注意】以上代码并不是等价的。 如果 empnull 或者不是 Person 对象, 那么第一个 is 的示例,后面将不执行; 第二个 as 的示例会抛出异常。

3. 赋值运算符

下面示例使用几个赋值和复合赋值运算符,其他使用方法类似:

1
2
3
4
5
a = 123;  // 使用 = 直接为变量赋值。

b ??= 456; // 使用 ??= 运算符时,只有当 b 值为 null 时才会被赋值

a *= 3; // 赋值并做乘法运算,相当于 a = a * 3

4. 逻辑运算符

下面是关于逻辑表达式的示例:

1
2
3
if (!done && (col == 0 || col == 3)) {
// ...Do something...
}

5. 位运算符

位运算符把数字转为二进制来运算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 按位与,参与运算的两个值,如果两个相应二进位都为 1,则该位结果为 1,否则为 0
print(a & b); // 结果为 4

// 按位或,只要对应的两个二进位有一个为 1,结果位就为 1
print(a | b); // 结果为 14

// 按位异或,当两个对应的二进位相异时,结果位为 1,否则为 0
print(a ^ b); // 结果为 10

// 按位取反,对数据的每一个二进位,把 1 变为 0,把 0 变为 1
print(~a); // 结果我为 -13

// 左移,把 << 左边的数的各二进位左移若干位,由 << 右边的数指定移动位数,高位丢弃,低位补 0
print(a << 2); // 结果为 48,相当于乘以 2 的 2 次方

// 右移,把 << 左边的数的各二进位右移若干位,由 << 右边的数指定移动位数
print(a >> 2); // 结果为 3,相当于除以 2 的 2 次方

6. 条件表达式

Dart有两个运算符,有时可以替换 if-else 表达式, 让表达式更简洁,称为条件表达式:

1
condition ? expr1 : expr2

如果条件为 true, 执行 expr1 (并返回它的值): 否则, 执行并返回 expr2 的值。

1
expr1 ?? expr2

如果 expr1 不是 null, 返回 expr1 的值; 否则, 执行并返回 expr2 的值。

如果条件判断是根据布尔值, 考虑使用第一种,如果是根据是否为 null,考虑使用第二种。

1
2
3
var a = 'Dart';
var b = 'Flutter';
var c = a ?? b.toUpperCase(); // 此处 c 的字面量为 Dart

7. 级联运算符

级联运算符 .. 可以实现对同一个对像进行一系列的操作。 除了调用函数外, 还可以访问同一对象上的字段属性。 这通常可以节省创建临时变量的步骤, 同时编写出更流畅的代码。

1
2
3
4
querySelector('#confirm')   // 获取对象。
..text = 'Confirm' // 调用成员变量。
..classes.add('important')
..onClick.listen((e) => window.alert('Confirmed!'));

第一句调用函数 querySelector() ,返回获取到的对象。 获取的对象依次执行级联运算符后面的代码, 代码执行后的返回值会被忽略。

上面的代码等价于:

1
2
3
4
var button = querySelector('#confirm');
button.text = 'Confirm';
button.classes.add('important');
button.onClick.listen((e) => window.alert('Confirmed!'));

级联运算符是可以嵌套的。但要注意,在返回对象的函数中谨慎使用级联操作符。 例如,下面的代码是错误的:

1
2
3
var sb = StringBuffer();
sb.write('foo')
..write('bar'); // sb.write() 函数调用返回 void, 不能在 void 对象上创建级联操作。六、流程控制语句

八. 流程控制语句

Dart 中的流程控制语句与 Java 相似。

1. if - else 分支

和 JavaScript 不同, Dart 的判断条件必须是布尔值,不能是其他类型。

1
2
3
4
5
6
int a = 1;
if (a < 0) {
a++;
} else if (a > 0) {
a--;
}

2. switch - case 语句

在 Dart 中 switch 语句使用 == 比较整数、字符串、编译时常量或者枚举类型。 比较的对象必须都是同一个类的实例(并且不可以是子类), 类必须没有对 == 重写。

case 语句中,每个非空的 case 语句结尾需要跟一个 break 语句。 除 break以外,还有可以使用 continuethrow 或者 return。当没有 case 语句匹配时,执行 default 代码:

1
2
3
4
5
6
7
8
9
10
11
var a = 'yes';
switch (a) {
case 'yes':
print('yes');
break;
case 'no':
print('no');
break;
default:
print('fault');
}

3. for 循环

进行迭代操作,可以使用标准 for 语句。 例如:

1
2
3
4
5
// 计算 5 的阶乘
int result = 1;
for (int i = 1; i < 6; i++) {
result = result * i;
}

闭包在 Dart 的 for 循环中会捕获循环的初始索引值, 来避免 JavaScript 中常见的陷阱。下面的代码输出的是 0 和 1,但是在 JavaScript 中会连续输出两个 2 :

1
2
3
4
5
var callbacks = [];
for (var i = 0; i < 2; i++) {
callbacks.add(() => print(i));
}
callbacks.forEach((c) => c());

4. while 和 do while 循环

while 循环是在执行前判断执行条件,do-while 循环是在执行后判断执行条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 计算 5 的阶乘
int i = 1;
int result = 1;
// 使用 while 循环
while (i < 6) {
result = result * i;
i++;
}
// 使用 do - while 循环
do {
result = result * i;
i++;
} while (i < 6);

5. break 和 continue

使用 break 停止程序循环:

1
2
3
4
5
6
for (int i = 1; i < 5; i++) {
if (i == 3) {
break;
}
print(i); // 输出结果为 1 2(i = 3 时循环结束)
}

使用 continue 跳转到下一次循环:

1
2
3
4
5
6
for (int i = 1; i < 5; i++) {
if (i == 3) {
continue;
}
print(i); // 输出结果为 1 2 4(i = 3 时跳到下一次循环)
}

6. 其他循环

使用 for...in... 循环:

1
2
3
4
5
// 遍历数组
List a = ['Dart', 'Flutter', 'Android'];
for (var item in a) {
print(item);
}

使用 forEach 循环:

1
2
3
4
5
// 遍历数组
List a = ['Dart', 'Flutter', 'Android'];
a.forEach((var item) {
print(item);
});

7. assert 语句

使用 assert 语句进行判断,如果 assert 语句中的布尔条件为 false , 那么正常的程序执行流程会被中断:

1
2
3
4
5
// 确认变量值不为空。
assert(text != null);

// 确认变量值小于100。
assert(number < 100);

【注意】assert 语句只在开发环境中有效, 在生产环境是无效的; Flutter 中的 assert 只在 debug 模式中有效。

九. 异常处理

Dart 代码可以抛出和捕获异常。和 Java 有所不同, Dart 中的所有异常是非检查异常。方法不会声明它们抛出的异常,也不要求捕获任何异常。

Dart 提供了 ExceptionError 类型, 以及一些子类型,也可以定义自己的异常类型。Dart 程序可以抛出任何非 null 对象, 不仅限 ExceptionError 对象。

1. throw 抛出异常

下面是关于抛出或者引发异常的示例:

1
throw FormatException('Expected at least 1 section');

也可以抛出任意的对象:

1
throw 'Out of llamas!';

2. try…catch…finally 捕获异常

Dart 语言中通过指定多个 catch 语句,可以处理可能抛出多种类型异常的代码,并由与抛出异常类型匹配的第一个 catch 语句处理异常。 如果 catch 语句未指定类型, 则该语句可以处理任何类型的抛出对象。

此外,catch() 函数可以指定1到2个参数, 第一个参数为抛出的异常对象, 第二个为堆栈信息。

使用 finally 语句时,无论是否抛出异常,finally 中的代码都会被执行。

示例代码如下:

1
2
3
4
5
6
7
8
9
try {
print(10 ~/ 0);
} on IntegerDivisionByZeroException catch (e, s) {
print(e);
print(s);
} on Exception catch (e, s) {
print(e);
print(s);
}

执行以上代码将输出:

1
2
3
4
5
6
7
8
9
// 捕获的异常
IntegerDivisionByZeroException
// 堆栈信息
#0 int.~/ (dart:core-patch/integers.dart:22:7)
#1 main (file:///c:/Users/27884/Desktop/test.dart:3:14)
#2 _startIsolate.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:301:19)
#3 _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:168:12)
// finally 语句后的代码执行结果
end

1、环境及基础

2、优秀开源

https://github.com/toly1994328/FlutterUnit 项目架构设计,UI及动画学习
http://laomengit.com UI、动画学习
https://github.com/fluttercandies/ flutter 一桶天下
https://help.syncfusion.com/flutter/introduction/overview pdf,图表,日历,地图,表格等硬核插件

3、较活跃的论坛

4、公众号

  • Flutter社区
  • Flutter编程指南
  • 谷歌开发者
  • 老孟Flutter
  • flutter开发精选

5、关于cocoapods

  • 不推荐使用bundle管理多个cocoapods版本。

  • 备注:如果cocoapods安装指定版本不成功试下:

    MacOS 10.11以后用此命令安装或卸载cocoapods(为了运行Flutter)
    sudo gem install -n /usr/local/bin cocoapods -v 1.11.3
    sudo gem uninstall -n /usr/local/bin cocoapods -v 1.11.3

相同点

  1. 定义存储值的属性
  2. 定义方法
  3. 定义下标以及使用下标语法提供对其值的访问
  4. 定义初始化器
  5. 使用extension来拓展功能
  6. 遵循协议来提供功能

不同点

  1. 类有继承特性,结构体没有
  2. 类型转换,在运行时检查和解释类实例的类型
  3. 类有析构函数用来释放器分配的资源
  4. 引用计数允许对一个类实例有多个引用
  5. 类是引用类型,存储在堆上,也就意味着一个类类型的变量并不是直接存储具体的的实例对象,是对当前存储具体实例内存地址的引用。
  6. Struct结构体是值类型,存储在栈上。如果值类型中有引用类型的属性,则该属性的地址在栈上,但是引用的值存储在堆上。
  7. 尽可能多的使用值类型,是内存安全的,运行效率也会比值类型高。

初始化:

  1. 类中添加属性,不会自动提供成员初始化器,必须提供对应的的指定初始化器(或者提供便捷初始化器)。
  2. 结构体会提供默认的成员初始化器。

便捷初始化器

1
2
3
4
5
6
7
8
9
init(name: String, age: Int) {
this.name = name
this.age = age
}

convenience init(){
// 这里需要调用初始化
self.init(name: "name", age: 18)
}

便捷初始化器必须从相同的类里调用另一个初始化器,否则会报错。

重点:

  1. 指定初始化器必须在调用父类初始化器之前,对其所有属性完成初始化。(确保成员变量使用安全)
  2. 指定初始化器必须在调用父类初始化器之后,才能为继承的属性设置新值,否则指定初始化器中赋的新值会被父类的初始化所覆盖。
  3. 便捷初始化器必须先调用同类中的初始化器之后,再为任意属性赋值,否则会报错,(属性的值也会被指定初始化覆盖)。
  4. 初始化器在初始化完成之前,不能调用任何实例方法,不能读取任何实例属性的值,也不能引用self作为值。必须保证内存是安全的

可失败初始化:init?

当参数不合法或者条件不满足时存在失败的情况,return nil。

必要初始化:require

在类的初始化之前添加required关键字来修饰。表明该类的子类都必须实现该初始化器。

类的生命周期

refCount :64位的位域信息

核心原则

代码应该简洁易懂,逻辑清晰;
代码应优先保证正确性、可用性;
在保证程序可用的情况下,代码应该具备可扩展性,易修改,而不是需求有一点改动代码就需要大动干戈;

禁止使用print直接提交到发版分支,使用debugPrint替换。

1
2
3
4
5
6
7
8
void tryCatch(Function f) {
try {
f?.call();
} catch (e, stack) {
debugPrint('$e');
debugPrint('$stack');
}
}

字符串

两个常量字符串(不是变量,是放在引号中的字符串),你不需要使用 + 来连接它们。

推荐的写法

1
2
3
print(
'ERROR: Parts of the spaceship are on fire. Other '
'parts are overrun by martians. Unclear which are which.');

不推荐的写法

1
2
print('ERROR: Parts of the spaceship are on fire. Other ' +
'parts are overrun by martians. Unclear which are which.');

优先使用插值来组合字符串和值。

如果您之前是用其他语言做开发的,那么您习惯使用+的长链来构建文字和其他值的字符串。 这在Dart中有效,但使用插值总是更清晰,更简短:

推荐写法

1
'Hello, $name! You are ${year - birth} years old.';

不推荐的写法:

1
'Hello, ' + name + '! You are ' + (year - birth).toString() + ' y...';

不要在字符串中使用不必要的大括号

当表达式的值可以为真、假或null,并且您需要将结果传递给不接受null的对象时,此规则适用。一个常见的情况是一个判断空值的方法调用被用作条件:

推荐的写法

1
2
3
4
'Hi, $name!'
"Wear your wildest $decade's outfit."
//标识符后面有紧跟着的字母了 加上大括号用以区分
'Wear your wildest ${decade}s outfit.'

不推荐的写法

1
2
'Hi, ${name}!'
"Wear your wildest ${decade}'s outfit."

使用? ?将空值转换为布尔值。

不推荐的写法

1
2
3
if (optionalThing?.isEnabled) {
print("Have enabled thing.");
}

如果optionalThing为空,此代码将抛出异常。(if只支持判断bool值,不支持null)要解决这个问题,您需要将null值“转换”为true或false。虽然您可以使用==来完成此操作,但我们建议使用?? :

推荐的写法

1
2
3
4
//如果你想要optionalThing是空值时返回false
optionalThing?.isEnabled ?? false;
//如果你想要optionalThing是空值时返回true
optionalThing?.isEnabled ?? true;

不推荐的写法

1
2
3
4
// 如果你想要optionalThing是空值时返回false
optionalThing?.isEnabled == true;
// 如果你想要optionalThing是空值时返回true
optionalThing?.isEnabled != false;

集合

尽可能的使用集合字面量。

两种方式来构造一个空的可变 list : [] 和 List() 。 同样,有三种方式来构造一个空的Map map:{}, Map(), 和 LinkedHashMap() 。 如果想创建一个固定不变的 list 或者其他自定义集合类型,这种情况下你需要使用构造函数。 否则,使用字面量语法更加优雅。 核心库中暴露这些构造函数易于扩展,但是通常在 Dart 代码中并不使用构造函数。

推荐的写法

1
2
var points = [];
var addresses = {};

不推荐的写法

1
2
var points = List();
var addresses = Map();

如果需要的话,你可以提供一个泛型

推荐的写法

1
2
var points = <Point>[];
var addresses = <String, Address>{};

不推荐的写法

1
2
var points = List<Point>();
var addresses = Map<String, Address>();

注意,对于集合类的 命名 构造函数则不适用上面的规则。 List.from()、 Map.fromIterable() 都有其使用场景。 如果需要一个固定长度的结合,使用 List() 来创建一个固定长度的 list 也是合理的。

不要使用 .length 来判断一个集合是否为空。

通过调用 .length 来判断集合是否包含内容是非常低效的。相反,Dart 提供了更加高效率和易用的 getter 函数:.isEmpty 和.isNotEmpty。 使用这些函数并不需要对结果再次取非(list.length ! =0)

推荐的写法

1
2
if (lunchBox.isEmpty) return 'so hungry...';
if (words.isNotEmpty) return words.join(' ');

不推荐的写法

1
2
if (lunchBox.length == 0) return 'so hungry...';
if (!words.isEmpty) return words.join(' ');

不要使用 List.from() 除非想修改结果的类型。

给定一个可迭代的对象,有两种常见方式来生成一个包含相同元素的 list:

1
2
var copy1 = iterable.toList();
var copy2 = List.from(iterable);

推荐的写法
明显的区别是前一个更短。 更重要的区别在于第一个保留了原始对象的类型参数:

1
2
3
4
5
// 创建一个 List<int>:
var iterable = [1, 2, 3];

// 输出 "List<int>":
print(iterable.toList().runtimeType);

不推荐的写法

1
2
3
4
5
// 创建一个 List<int>:
var iterable = [1, 2, 3];

// 输出 "List<dynamic>":
print(List.from(iterable).runtimeType);

如果你想要改变原始对象的类型参数,那么可以调用 List.from() :

推荐的写法

1
2
3
var numbers = [1, 2.3, 4]; // List<num>.
numbers.removeAt(1); // 现在集合里只包含int型
var ints = List<int>.from(numbers);

但是如果你的目的只是复制可迭代对象并且保留元素原始类型, 或者并不在乎类型,那么请使用 toList() 。

参数

使用 = 来分隔参数名和参数默认值。

由于遗留原因,Dart 同时支持 : 和 = 作为参数名和默认值的分隔符。 为了与可选的位置参数保持一致,请使用 = 。

推荐的写法

1
void insert(Object item, {int at = 0}) { ... }

不推荐的写法

1
void insert(Object item, {int at: 0}) { ... }

成员

不要 为字段创建不必要的 getter 和 setter 方法

推荐的写法

1
2
3
class Box {
var contents;
}

不推荐的写法

1
2
3
4
5
6
7
class Box {
var _contents;
get contents => _contents;
set contents(value) {
_contents = value;
}
}

不要使用this. 在重定向命名函数和避免冲突的情况下除外

只有当局部变量和成员变量名字一样的时候,你才需要使用 this. 来访问成员变量。 只有两种情况需要使用 this. 。其中一种情况是要访问的局部变量和成员变量命名一样的时候:

推荐的写法

1
2
3
4
5
6
7
8
9
10
11
class Box {
var value;

void clear() {
update(null);
}

void update(value) {
this.value = value;
}
}

不推荐的写法

1
2
3
4
5
6
7
8
9
10
11
class Box {
var value;

void clear() {
this.update(null);
}

void update(value) {
this.value = value;
}
}

要尽可能的在定义变量的时候初始化变量值。

如果一个字段不依赖于构造函数中的参数, 则应该在定义的时候就初始化字段值。 这样可以减少需要的代码并可以确保在有多个构造函数的时候你不会忘记初始化该字段。

不推荐的写法

1
2
3
4
5
6
7
class Folder {
final String name;
final List<Document> contents;

Folder(this.name) : contents = [];
Folder.temp() : name = 'temporary'; // Oops! Forgot contents.
}

推荐的写法

1
2
3
4
5
6
7
class Folder {
final String name;
final List<Document> contents = [];

Folder(this.name);
Folder.temp() : name = 'temporary';
}

当然,对于变量取值依赖构造函数参数的情况以及不同的构造函数取值也不一样的情况, 则不适合本条规则。

构造函数

不要 使用 new

创建对象不要使用new

推荐的写法

1
2
3
4
5
6
7
8
9
10
Widget build(BuildContext context) {
return Row(
children: [
RaisedButton(
child: Text('Increment'),
),
Text('Click!'),
],
);
}

不推荐的写法

1
2
3
4
5
6
7
8
9
10
Widget build(BuildContext context) {
return new Row(
children: [
new RaisedButton(
child: new Text('Increment'),
),
new Text('Click!'),
],
);
}

要用 ; 来替代空的构造函数体 {}。

在 Dart 中,没有具体函数体的构造函数可以使用分号结尾。 (事实上,这是不可变构造函数的要求。)

推荐的写法

1
2
3
4
class Point {
int x, y;
Point(this.x, this.y);
}

不推荐的写法

1
2
3
4
class Point {
int x, y;
Point(this.x, this.y) {}
}

要尽可能的使用初始化形式。

不推荐的写法

1
2
3
4
5
6
7
class Point {
num x, y;
Point(num x, num y) {
this.x = x;
this.y = y;
}
}

推荐的写法

1
2
3
4
class Point {
num x, y,z;
Point(this.x, this.y,{this.z});
}

这里的位于构造函数参数之前的 this. 语法被称之为初始化形式(initializing formal)。 有些情况下这无法使用这种形式。特别是,这种形式下在初始化列表中无法看到变量。 但是如果能使用该方式,就应该尽量使用。(如果使用命名参数)

函数返回值

dart允许方法不写返回值类型,但是在编码过程中建议将返回值类型写清楚。

不推荐

1
foo() {}

推荐

1
void foo() {}

异步

推荐 使用 async/await 而不是直接使用底层的特性。

显式的异步代码是非常难以阅读和调试的, 即使使用很好的抽象(比如 future)也是如此。 这就是为何 Dart 提供了 async/await。 这样可以显著的提高代码的可读性并且让你可以在异步代码中使用语言提供的所有流程控制语句。

推荐的写法

1
2
3
4
5
6
7
8
9
10
11
12
Future<int> countActivePlayers(String teamName) async {
try {
var team = await downloadTeam(teamName);
if (team == null) return 0;

var players = await team.roster;
return players.where((player) => player.isActive).length;
} catch (e) {
log.error(e);
return 0;
}
}

不推荐写法

1
2
3
4
5
6
7
8
9
10
11
12
Future<int> countActivePlayers(String teamName) {
return downloadTeam(teamName).then((team) {
if (team == null) return Future.value(0);

return team.roster.then((players) {
return players.where((player) => player.isActive).length;
});
}).catchError((e) {
log.error(e);
return 0;
});
}

标识符

在 Dart 中标识符有三种类型。 • UpperCamelCase 每个单词的首字母都大写,包含第一个单词。 • lowerCamelCase 每个单词的首字母都大写,除了第一个单词, 第一个单词首字母小写,即使是缩略词。 • lowercase_with_underscores 只是用小写字母单词,即使是缩略词, 并且单词之间使用 _ 连接。

使用 UpperCamelCase 风格命名类型。

Classes(类名)、 enums(枚举类型)、 typedefs(类型定义)、 以及 type parameters(类型参数)应该把每个单词的首字母都大写(包含第一个单词), 不使用分隔符。

1
2
3
4
5
class SliderMenu { ... }

class HttpRequest { ... }

typedef Predicate = bool Function<T>(T value);

要在库,包,文件夹,源文件中使用 lowercase_with_underscores 方式命名要用 lowercase_with_underscores 风格命名库和源文件名。

推荐的写法

1
2
3
4
library peg_parser.source_scanner;

import 'file_system.dart';
import 'slider_menu.dart';

不推荐的写法

1
2
3
library pegparser.SourceScanner;
import 'file-system.dart';
import 'SliderMenu.dart';

要使用 lowercase_with_underscores 风格命名导入的前缀

推荐的写法

1
2
3
4
import 'dart:math' as math;
import 'package:angular_components/angular_components'
as angular_components;
import 'package:js/js.dart' as js;

不推荐的写法

1
2
3
4
import 'dart:math' as Math;
import 'package:angular_components/angular_components'
as angularComponents;
import 'package:js/js.dart' as JS;

要 使用 lowerCamelCase 风格来命名其他的标识符。

类成员、顶级定义、变量、参数以及命名参数等 除了第一个单词,每个单词首字母都应大写,并且不使用分隔符。

推荐的写法

1
2
3
4
5
6
7
var item;

HttpRequest httpRequest;

void align(bool clearItems) {
// ...
}

要把超过两个字母的首字母大写缩略词和缩写词当做普通单词。

首字母大写缩略词比较难阅读, 特别是多个缩略词连载一起的时候会引起歧义。 例如,一个以 HTTPSFTP 开头的名字, 没有办法判断它是指 HTTPS FTP 还是 HTTP SFTP 。 为了避免上面的情况,缩略词和缩写词要像普通单词一样首字母大写, 两个字母的单词除外。 (像 ID 和 Mr. 这样的双字母缩写词仍然像一般单词一样首字母大写。)

推荐的写法

1
2
3
4
5
6
HttpConnectionInfo
uiHandler
IOStream
HttpRequest
Id
DB

不推荐的写法

1
2
3
4
5
6
HTTPConnection
UiHandler
IoStream
HTTPRequest
ID
Db

• acronyms :首字母缩略词,指取若干单词首字母组成一个新单词,如:HTTP = HyperText Transfer Protocol • abbreviations : 缩写词,指取某一单词的部分字母(或其他缩短单词的方式)代表整个单词,如:ID = identification

不要 使用前缀字母

推荐的写法

1
defaultTimeout

不推荐的写法

1
kDefaultTimeout

要 使用 googlestyle 格式化你的代码

格式化是一项繁琐的工作,尤其在重构过程中特别耗时。 庆幸的是,你不必担心。 使用Android studio默认的googlestyle。

要对所有流控制结构使用花括号。

这样可以避免 dangling else (else悬挂)的问题。

1
2
3
4
5
if (isWeekDay) {
print('Bike to work!');
} else {
print('Go dancing or read a book!');
}

这里有一个例外:一个没有 else 的 if 语句, 并且这个 if 语句以及它的执行体适合在一行中实现。 在这种情况下,如果您愿意,可以不用括号

1
if (arg == null) return defaultValue;

但是,如果执行体包含下一行,请使用大括号:

推荐的写法

1
2
3
if (overflowChars != other.overflowChars) {
return overflowChars < other.overflowChars;
}

不推荐的写法

1
2
if (overflowChars != other.overflowChars)
return overflowChars < other.overflowChars;