一、前言

上一篇《神策分析 iOS SDK 代码埋点解析》主要介绍了如何设计与实现代码埋点。具体来讲,就是实现了一个 – track: 接口,可以在合适的时机调用,来记录一条用户的行为数据。一般情况下,对于不同的 App,有价值的行为数据是不一样的,调用 – track: 接口的时机自然也是不一样的,需要开发者根据业务场景来手动调用。

对于 App 而言,有些特定的且有分析意义的用户行为我们可以在 SDK 直接采集。例如:App 启动、App 退出、元素点击、页面浏览等。为了将其与代码埋点区分开,我们称之为全埋点(也叫无埋点、无码埋点、无痕埋点、自动埋点)。

不难看出,全埋点主要面临两个难点:

1、时机:如何在事件发生的时机,插入采集事件的代码?

2、属性:除了默认采集的预置属性外,是否可以采集其他有意义的预置属性?以及如何为这些事件补充自定义属性?

接下来的全埋点解析系列博客,主要就是来解决上面这两个难点。本文主要讨论 App 启动与退出事件的采集。

二、应用程序状态

在讨论 App 启动与退出事件的采集之前,先要了解这两个事件本身的意义。这里需要介绍下 App 的几种运行状态:

typedef NS_ENUM(NSInteger, UIApplicationState) {
    UIApplicationStateActive,
    UIApplicationStateInactive,
    UIApplicationStateBackground
} API_AVAILABLE(ios(4.0));

App 在执行时可能的几种状态:

1、Active:程序运行在 Foreground,且正在接收事件;

2、Inactive:程序运行在 Foreground,但未接收事件。这可能是由于以下几种原因引起的:中断(例如:传入电话或SMS消息)、应用正在过渡到后台、应用从后台过渡而来;

3、Background:程序运行在 Background,且正在执行代码。

此外,App 还会有两种没有执行代码的状态:

1、Not Running:程序未运行。App 首次安装还未启动、App 被 Kill、手机重启后还未运行 App 等均会处于此状态;

2、Suspended:程序运行在 Background,但没有执行代码,处于挂起状态。大部分应用进入后台,都会在短暂时间内被系统切换为挂起状态。

这五个状态即为 App 所有的运行状态,如图 2-1 所示:

图 2-1 App 运行状态(图片来源于 Apple 开发者官网)

当应用程序的运行状态发生变化时,会回调 UIApplicationDelegate 中的协议方法,默认是由 AppDelegate 实现的,如表 2-1 所示:

表 2-1 UIApplicationDelegate 中的协议方法

这里需要注意的是,并不是每一种状态变化都会有对应的方法,如图 2-1 中红框内的两个变化就没有对应的方法。

App 启动与退出事件的采集,应当在这些方法与通知中寻找思路。下面列举下常见的运行状态变化的场景

1、冷启动,也即 Kill App 之后启动,或 App 安装后第一次启动(Not Running -> Inactive -> Active)

2、App 返回主屏幕(Active -> Inactive -> Background -> Suspended)若在 Info.plist 中设置 Application does not run in background 为 YES,则 App 返回主屏幕后会立即被 Kill(Active -> Inactive -> Background -> Suspended -> Not Running)

3、App 内进入App 切换器,然后直接返回 App(Active -> Inactive -> Active)

4、App 内进入App 切换器,然后进入主屏幕(Active -> Inactive -> Background -> Suspended)

5、App 内进入App 切换器,然后 Kill App(Active -> Inactive -> Background -> Suspended -> Not Running)

6、App 挂起状态重新运行,即热启动(Suspended -> Background -> Inactive -> Active)

7、App 挂起状态时 Kill  App 或直接删除 App(Suspended -> Not Running)

三、App 启动

App 启动是指应用程序启动,同时包括冷启动和热启动场景。冷启动与热启动会涉及到不同的 App 应用状态方法,因此采集方式也是不相同的,下面分开讨论。

3.1 冷启动

3.1.1 采集方案

冷启动,即 Kill App 之后启动,或 App 安装后第一次启动。采集方法如下:

- (void)autoTrackAppStart {
    // 是否开启 $AppStart 全埋点
    if ([self isAutoTrackEventTypeIgnored:SensorsAnalyticsEventTypeAppStart]) {
        return;
    }
    // 由于一次完整的应用生命周期只会触发一次冷启动,因此添加 dispatch_once 以防止多次触发。
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 是否首次启动,记录到 SA_EVENT_PROPERTY_APP_FIRST_START 中。标记存到 NSUserDefaults 中。
        BOOL isFirstStart = NO;
        if (![[NSUserDefaults standardUserDefaults] boolForKey:SA_HAS_LAUNCHED_ONCE]) {
            isFirstStart = YES;
            [[NSUserDefaults standardUserDefaults] setBool:YES forKey:SA_HAS_LAUNCHED_ONCE];
        }
        // 判断是不是被动启动。被动启动会记录到 App 被动启动事件中。后面会细讲 App 被动启动。
        NSString *eventName = [self isLaunchedPassively] ? SA_EVENT_NAME_APP_START_PASSIVELY : SA_EVENT_NAME_APP_START;
        // SA_EVENT_PROPERTY_RESUME_FROM_BACKGROUND:App 是否从后台恢复。以此区分冷热启动。
        NSDictionary *properties = @{SA_EVENT_PROPERTY_RESUME_FROM_BACKGROUND: @(NO), SA_EVENT_PROPERTY_APP_FIRST_START: @(isFirstStart)};
        [self track:eventName withProperties:properties withTrackType:SensorsAnalyticsTrackTypeAuto];
        // 启动 $AppEnd 事件计时器
        [self trackTimerStart:SA_EVENT_NAME_APP_END];
    });
}

接下来需要解决的问题,就是采集时机。从上面对 App 应用状态的描述中知道,冷启动的过程中 App 会经历 Not Running -> Inactive -> Active 这一流程,也即执行如下两个方法:

  • – application:didFinishLaunchingWithOptions:
  • – applicationDidBecomeActive:

由于 – applicationDidBecomeActive: 在多个场景都会被调用,而 – application:didFinishLaunchingWithOptions: 仅会在冷启动时被调用,故选择在 – application:didFinishLaunchingWithOptions: 方法中采集冷启动事件即可。代码如下:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [self autoTrackAppStart];
    return YES;
}

3.1.2 方案优化

由于我们设计的是 SDK,那么代码就应该实现高内聚低耦合的目标。因此,需要对上面的代码进行一些改造:

1、- autoTrackAppStart 应为 SDK 的方法,它会依赖 SDK 的初始化;

2、SDK 有一些设置方法应在采集第一个事件之前设置好,例如:设置公共属性等。

于是,代码改造如下:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // 初始化 SDK
    [SensorsAnalyticsSDK startWithConfigOptions:nil];
    // 初始化公共属性
    [[SensorsAnalyticsSDK sharedInstance] registerSuperProperties:{@"key":@"value"}];
    // 采集冷启动
    [[SensorsAnalyticsSDK sharedInstance] autoTrackAppStart];
    return YES;
}

上述代码实现了 SDK 的初级目标,但仍存在一些需要改进的问题:

1、代码之间有较严格的执行顺序要求,例如:- autoTrackAppStart 必须放到 – registerSuperProperties: 后面,这无形中给开发者增加了集成难度;

2、- autoTrackAppStart 作为一个采集冷启动事件的方法,不应暴露在 SDK 外。它作为全埋点的一部分,对外暴露一个设置全埋点的接口即可;

3、因为全埋点涉及到监听系统的方法,所以目前我们希望将 “设置全埋点类型” 作为一个 SDK 的初始化参数,不建议在初始化后再去设置或修改。

基于以上原因,我们新增了 SAConfigOptions 这个类,用于配置 SDK 的初始化参数。在 SAConfigOptions 中设置 autoTrackEventType 属性,用于设置全埋点属性,如下所示:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // 配置 SDK 初始化参数;SERVER_URL 是数据接收地址
    SAConfigOptions *options = [SAConfigOptions.alloc initWithServerURL:SA_SERVER_URL launchOptions:launchOptions];
    // 配置开启全埋点:冷启动
    [options setAutoTrackEventType:SensorsAnalyticsEventTypeAppStart];
    // 初始化 SDK
    [SensorsAnalyticsSDK startWithConfigOptions:options];
    // 初始化公共属性
    [[SensorsAnalyticsSDK sharedInstance] registerSuperProperties:{@"key":@"value"}];
    return YES;
}

为了确保 – autoTrackAppStart 方法可以在 – registerSuperProperties: 及其他 SDK 的设置方法后再执行,采用如下方案:

1、在 SDK 初始化方法 + startWithConfigOptions: 中监听 UIApplicationDidFinishLaunchingNotification 通知,该通知会在 – didFinishLaunchingWithOptions: 执行完毕后发出;

2、在监听到 UIApplicationDidFinishLaunchingNotification 之后,调用 – autoTrackAppStart 方法。

因此,SDK 的初始化方法设计如下(代码中包括所有已提到的 SDK 需要初始化的内容):

+ (void)startWithConfigOptions:(SAConfigOptions *)configOptions {
    NSAssert(sensorsdata_is_same_queue(dispatch_get_main_queue()), @"神策 iOS SDK 必须在主线程里进行初始化,否则会引发无法预料的问题(比如丢失 $AppStart 事件)。");
    dispatch_once(&sdkInitializeOnceToken, ^{
        sharedInstance = [[SensorsAnalyticsSDK alloc] initWithConfigOptions:configOptions debugMode:SensorsAnalyticsDebugOff];
    });
}
- (instancetype)initWithConfigOptions:(nonnull SAConfigOptions *)configOptions debugMode:(SensorsAnalyticsDebugMode)debugMode {
    @try {
        self = [super init];
        if (self) {
            _configOptions = [configOptions copy];
            dispatch_block_t mainThreadBlock = ^(){
                //判断被动启动
                if (UIApplication.sharedApplication.applicationState == UIApplicationStateBackground) {
                    self->_launchedPassively = YES;
                }
            };
            sensorsdata_dispatch_main_safe_sync(mainThreadBlock);
            // Debug 模式
            _debugMode = debugMode;
            // 数据接收地址
            _network = [[SANetwork alloc] initWithServerURL:[NSURL URLWithString:_configOptions.serverURL]];
            // 是否为热启动
            _appRelaunched = NO;
            // 防止发重复的 App 退出事件
            _applicationWillResignActive = NO;
            // 计时器
             _trackTimer = [[SATrackTimer alloc] init];
            // 取上一次进程退出时保存的 distinctId、loginId、superProperties
            [self unarchive];
            // 是否首日访问
            if (self.firstDay == nil) {
                NSDateFormatter *dateFormatter = [SADateFormatter dateFormatterFromString:@"yyyy-MM-dd"];
                self.firstDay = [dateFormatter stringFromDate:[NSDate date]];
                [self archiveFirstDay];
            }
            // 收集预置属性
            self.automaticProperties = [self collectAutomaticProperties];
            // 监听通知
            [self setUpListeners];
        }
    } @catch(NSException *exception) {
        SAError(@"%@ error: %@", self, exception);
    }
    return self;
}
- (void)setUpListeners {
    // 监听 App 启动或结束事件
    NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
    [notificationCenter addObserver:self selector:@selector(applicationDidFinishLaunching:) name:UIApplicationDidFinishLaunchingNotification object:nil];
    [notificationCenter addObserver:self selector:@selector(applicationWillEnterForeground:) name:UIApplicationWillEnterForegroundNotification object:nil];
    [notificationCenter addObserver:self selector:@selector(applicationDidBecomeActive:) name:UIApplicationDidBecomeActiveNotification object:nil];
    [notificationCenter addObserver:self selector:@selector(applicationWillResignActive:) name:UIApplicationWillResignActiveNotification object:nil];
    [notificationCenter addObserver:self selector:@selector(applicationDidEnterBackground:) name:UIApplicationDidEnterBackgroundNotification object:nil];
}
- (void)applicationDidFinishLaunching:(NSNotification *)notification {
    // 采集冷启动事件
    [self autoTrackAppStart];
}

到目前为止,该方案还存在一个问题:开发者在集成 SDK 时,有可能在 UIApplicationDidFinishLaunchingNotification 通知发出后才初始化 SDK,这样就会采集不到冷启动事件。例如:

1、在 – application:didFinishLaunchingWithOptions: 之后才初始化 SDK;

2、在 – application:didFinishLaunchingWithOptions: 中异步初始化 SDK;

3、App 启动时先网络请求数据接收地址,然后再初始化 SDK。

为了避免这些丢失冷启动事件的情况,可采取如下的解决方案:在 SDK 初始化时(即 – initWithConfigOptions:debugMode: 中),在主线程中异步再调用一次 – autoTrackAppStart 方法。该方案有以下优点:

1、由于 – autoTrackAppStart 方法中有 dispatch_once 保证代码只执行一次,因此不会重复触发冷启动事件;

2、若 App 已错过 UIApplicationDidFinishLaunchingNotification 通知,则该异步方法可保证仍会采集到冷启动事件;

3、主线程异步任务会在 SDK 相关初始化完成后才会执行,此时采集的冷启动事件不会丢失公共属性。

代码如下:

- (instancetype)initWithConfigOptions:(nonnull SAConfigOptions *)configOptions debugMode:(SensorsAnalyticsDebugMode)debugMode {
    ......
    dispatch_async(dispatch_get_main_queue(), ^{
        [self autoTrackAppStart];
    });
    ........
}

不过该方案也存在缺点:只适用于在主线程初始化 SDK ,无法解决在子线程初始化 SDK 的问题。因此,需要保证 SDK 必须在主线程中初始化,这也是 + startWithConfigOptions: 方法中添加判断主线程断言的原因。

3.2 热启动

热启动,即 App 处于 Supspended 状态下,从主屏幕进入 App 内的情况。采集方法如下:

- (void)trackRelaunchAppStart {
    // 追踪 AppStart 事件
    if ([self isAutoTrackEventTypeIgnored:SensorsAnalyticsEventTypeAppStart] == NO) {
        [self track:SA_EVENT_NAME_APP_START withProperties:@{SA_EVENT_PROPERTY_RESUME_FROM_BACKGROUND: @(_appRelaunched), SA_EVENT_PROPERTY_APP_FIRST_START: @(NO),} withTrackType:SensorsAnalyticsTrackTypeAuto];
    }
    // 启动 $AppEnd 事件计时器
    [self trackTimerStart:SA_EVENT_NAME_APP_END];
}

由前面的分析可知,此时的状态变化为: Suspended -> Background -> Inactive -> Active,即:

1、- applicationWillEnterForeground:

2、- applicationDidBecomeActive:

对于 SDK 来说,相对 UIApplicationDelegate 的协议方法,监听通知更为方便。因此,实际使用的是以下两个通知:

1、UIApplicationWillEnterForegroundNotification

2、UIApplicationDidBecomeActiveNotification

分析如下:

1、有许多情况下会出现 Inactive -> Active,故不可单独使用  UIApplicationDidBecomeActiveNotification;

2、发送 UIApplicationWillEnterForegroundNotification 时,appState 仍为 Background,有些代码执行可能有问题。

因此,需要综合使用上面两个通知,代码如下:

- (void)applicationWillEnterForeground:(NSNotification *)notification {
    // 标识符,通知 SDK 在接下来的通知 UIApplicationDidBecomeActiveNotification 中采集热启动事件
    _appRelaunched = YES;
    // 热启动,非被动启动
    self.launchedPassively = NO;
}
- (void)applicationDidBecomeActive:(NSNotification *)notification {
    // 非由后台进入前台,直接返回
    if (!_appRelaunched) {
        return;
    }
    _appRelaunched = NO;
    // 追踪 AppStart 事件
    [self trackRelaunchAppStart];
}

3.3 被动启动

3.3.1 相关概念

在 iOS 7 之后,苹果新增了后台应用程序刷新功能,该功能允许操作系统在一定的时间间隔内(这个时间间隔根据用户不同的操作习惯而有所不同,可能是几个小时,也可能是几天)拉起应用程序并同时让其进入后台运行,以便应用程序可以获取最新的数据并更新相关内容,从而可以确保用户在打开应用程序的时候可以第一时间查看到最新的内容。例如:新闻或者社交媒体类型的应用程序,可以使用这个功能在后台获取到最新的数据内容,在用户打开应用程序时可以缩短应用程序启动和获取内容展示的等待时间,最终提升产品的用户体验。

后台应用程序刷新,对于用户来说可以缩短等待时间;对于产品来说,可以提升用户体验;但对于数据采集 SDK 来说,可能会带来一系列的问题。例如:当系统拉起应用程序并同时让其进入后台运行时,应用程序的第一个页面(UIViewController)也会被加载,即会触发一次页面浏览事件,这明显是不合理的,因为用户并没有打开应用程序,更没有浏览第一个页面。其实,整个后台应用程序刷新的过程,对于用户而言,完全是透明的、无感知的。因此,在实际的数据采集过程中,我们需要避免这种情况的发生,以免影响到正常的数据分析。

这里我们把应用程序由 iOS 系统触发、自动进入后台运行,称之为(应用程序的)被动启动,通常使用 $AppStartPassively 事件来表示。后台应用程序刷新是最常见的造成被动启动的原因之一,而后台应用程序刷新只是其中一种后台运行模式,还有一些其他后台运行模式同样也会触发被动启动,下面我们会进行详细介绍。

3.3.2 Background Modes

使用 Xcode 创建新的应用程序,默认情况下后台刷新功能是关闭的,我们可以在 Capabilities 标签中开启 Background Modes,然后就可以勾选所需要的功能了,如图 3-1 所示:

图 3-1 Background Modes

由图 3-1 可知,还有如下几种后台运行模式,它们同样也会导致触发被动启动($AppStartPassively 事件)。

  • Location updates:此模式下,由于地理位置变化而触发应用程序启动;
  • Newsstand downloads:该模式只针对报刊杂志类应用程序,当有新的报刊可下载时,会触发应用程序启动;
  • External Accessory communication:该模式下,一些 MFi 外设通过蓝牙或者 Lightning 接头等方式与 iOS 设备连接,从而可在外设给应用程序发送消息时,触发对应的应用程序启动;
  • Uses Bluetooth LE accessories:该模式与 External Accessory communication 类似,只是无需限制 MFi 外设,而需要的是 Bluetooth LE 设备;
  • Acts as a Bluetooth LE accessory:该模式下,iPhone 作为一个蓝牙外设连接,可以触发应用程序启动;
  • Background fetch:该模式下,iOS 系统会在一定的时间间隔内触发应用程序启动,去获取应用程序数据;
  • Remote notifications:该模式是支持静默推送,当应用程序收到这种推送后,不会有任何界面提示,但会触发应用程序启动。

3.3.3 采集方案

后台应用程序刷新拉起应用程序后,首先会回调 AppDelegate 中的 – application:didFinishLaunchingWithOptions: 方法。因此,我们可以通过注册监听 UIApplicationDidFinishLaunchingNotification 本地通知来采集被动启动事件信息。

但是,这里有一个问题:对于应用程序正常的冷启动,也会发送 UIApplicationDidFinishLaunchingNotification 本地通知,导致正常的冷启动也会触发 $AppStartPassively 事件。那如何解决这个问题呢?

还是要通过第二节中讨论的 UIApplication 的 applicationState 来决定:

@property(nonatomic,readonly) UIApplicationState applicationState API_AVAILABLE(ios(4.0));

正常冷启动,applicationState 的值应该为 UIApplicationStateInactive;但若是被动启动,则该值会是 UIApplicationStateBackground。因此,当应用程序启动时,如果 applicationState 属性的值等于 UIApplicationStateBackground,那就意味着此时应用程序是被动启动的。这样即可解决冷启动也会触发被动启动事件的问题,代码如下:

- (instancetype)initWithConfigOptions:(nonnull SAConfigOptions *)configOptions debugMode:(SensorsAnalyticsDebugMode)debugMode {
    ......
            dispatch_block_t mainThreadBlock = ^(){
                // 判断被动启动
                if (UIApplication.sharedApplication.applicationState == UIApplicationStateBackground) {
                    self->_launchedPassively = YES;
                }
            };
    ......
}

四、App 退出

通过之前介绍的内容可知,当一个 App 退出(使用 $AppEnd 事件表示),就意味着该应用程序进入了 “后台”,即处于 Background 状态。因此,对于实现 $AppEnd 事件的全埋点,我们只需要注册监听 UIApplicationDidEnterBackgroundNotification 通知,然后在收到通知时触发 $AppEnd 事件,即可达到 $AppEnd 事件全埋点的效果。

4.1 采集方案

App 退出,分为以下几种情况:

1、App 内返回主屏幕;

2、App 进入切换器,然后返回主屏幕;

3、App 进入切换器,然后 Kill App。

无论哪种情况,都会执行 Active -> Inactive -> Background -> Suspended 这一状态转换。因此,应当考虑在以下两个通知之一记录该事件:

  • UIApplicationWillResignActiveNotification
  • UIApplicationDidEnterBackgroundNotification

同样,由于有许多情况下会出现 Active -> Inactive,因此不可单独使用 UIApplicationWillResignActiveNotification,代码如下:

- (void)applicationDidEnterBackground:(NSNotification *)notification {
    // 重置 "是否被动启动" 标记
    self.launchedPassively = NO;
    // 设置后台任务超时 block,若收到通知时结束后台任务
    UIApplication *application = UIApplication.sharedApplication;
    __block UIBackgroundTaskIdentifier backgroundTaskIdentifier = UIBackgroundTaskInvalid;
    backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^{
        [application endBackgroundTask:backgroundTaskIdentifier];
        backgroundTaskIdentifier = UIBackgroundTaskInvalid;
    }];
    // 追踪 $AppEnd 事件
    if ([self isAutoTrackEventTypeIgnored:SensorsAnalyticsEventTypeAppEnd] == NO) {
        [self track:SA_EVENT_NAME_APP_END withTrackType:SensorsAnalyticsTrackTypeAuto];
    }
}

这里需要说明的是,App 退出事件附带的 App 浏览时长属性的采集方式如下:

1、无论冷热启动触发时,我们都记录了 App 启动时的时间,并将其值存储起来(key 为 $AppEnd );

2、待采集 App 退出事件时,我们会用 $AppEnd 为 key 取出之前预存的 App 启动时间,与当前时间相减,得出的时长即为 App 浏览时长,存储到 $event_duration 属性中。

4.2 方案优化

上述方案存在一个问题:一些特殊情况下,application 会连续发出两次 UIApplicationDidEnterBackgroundNotification 通知。例如:回到主屏幕的同时进行锁屏,就会出现发出一次 UIApplicationWillResignActiveNotification 通知之后,又发出两次 UIApplicationDidEnterBackgroundNotification 通知。这样会采集到多余的退出事件,且第二次事件不会有 App 浏览时长,这显然是不正常的。

分析出了现象,解决方案也就呼之欲出了:在 App 进入后台前,会发送 UIApplicationWillResignActiveNotification 通知,此时用一个标记位表示应用注销了 Active 状态;当 App 进入后台时,会收到 UIApplicationDidEnterBackgroundNotification 通知,并将此标记变成 NO。因此,通过判断此标记为是否为 YES 即可,代码如下:

- (void)applicationWillResignActive:(NSNotification *)notification {
    _applicationWillResignActive = YES;
}
- (void)applicationDidEnterBackground:(NSNotification *)notification {
    if (!_applicationWillResignActive) {
        return;
    }
    _applicationWillResignActive = NO;
    ......
}

五、总结

本文是系列博客《神策分析 iOS SDK 源码解析》的第二篇,主要介绍关于 iOS 全埋点中启动与退出埋点的设计方案,以及 App 应用状态的相关知识。希望能为大家在学习全埋点技术的道路上带来一些帮助。

六、参考文献

Managing Your App’s Life Cycle(https://developer.apple.com/documentation/uikit/app_and_environment/managing_your_app_s_life_cycle?language=objc

七、本文作者

神策数据 | SDK 技术顾问

我是张占凯,热爱技术,目前专注于 iOS 领域。工作之余喜好读书,对各个领域的知识都有所涉猎。希望与大家在开源社区中共同探讨,共同进步。

、交流合作

本文著作权归神策数据开源社区所有。商业转载请联系我们获得授权;非商业转载请注明出处,并附上神策数据开源社区公众号二维码。
你还可以扫描二维码,加入社区交流群,与大家一同讨论。
也欢迎关注我们的公众号,博客更新尽在掌握。