在治理 IOS 应用崩溃之前我们肯定得需要先收集应用的 Crash 日志,然后可以参考我前几天写的 IOS Crash 日志分析调查入门实践 定位并解决问题。至于在得到设备的情况下能够在手机的设置中导出应用 Crash 日志或者使用 Xcode 导出日志的常规操作有很多前辈都在博客里提到了,我就不重复赘述了。那我们如果要收集普通用户的 Crash 日志的话(在无法得到用户设备的情况下),且用户不一定会去到设置里帮你找日志。当然,目前商业化的应用日志收集分析平台以及其 SDK 也比较全面了,相关的开源项目也有不少,如 Sentry 等…这个具体根据自己和公司项目的资源选择合适的即可。不过我由于是公司内部做了一个用户日志上报服务,于是想着能够利用这个服务统一上报客户端(以及客户端内核跨平台运行时)甚至是服务端日志一起上传到日志服务器中,所以我决定还是找一个开源的 Crash 日志记录工具。

找了一段时间,发现社区目前比较推荐的还是 KSCrash,看了一下仓库虽然这个库是比较有年代感(使用 Object-C 开发的)了,但是目前社区好像还是在继续活跃的(我也倡议有能力的小伙伴能参加一起贡献,哪怕是写一个文档、回复一个 issue 或提交 PR)。

在 Swift 中使用 KSCrash

虽然项目主要是由 C 和 Object-C 开发的,但项目中有 Package.swift 和 Objective-C Bridging Header 也就是说是支持 Swift 包管理进行安装和 Swift 调用的(虽然有些小坑,后面会讲到)。

你可以在 Xcode 中项目配置中 Package Dependencies 部分添加 https://github.com/kstenerud/KSCrash.git 作为依赖项,或者在你的 Package.swift 中添加

// swift-tools-version: 5.4
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    ……
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        .package(name: "KSCrash", url: "https://github.com/kstenerud/KSCrash.git", from: "1.17.2"),
    ],
    ……
)

参考文档我们需要在 AppDelegate.didFinishLaunchingWithOptions 函数中调用 KSCrashInstallationStandard(由于 KSCrash 支持的上报方式都是发送到服务端或者通过邮件发送,所以我这里只是演示一下如何使用 Swift 调用,后面我是需要实现将日志写入指定文件中的)

import KSCrash_Installations

@main
class AppDelegate: UIApplicationDelegate {
    func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        guard let installation = KSCrashInstallationStandard.sharedInstance() else {
            throw NSError(domain: "KSCrash installation failed", code: -1)
        }
        installation.url = URL(string: "http://put.your.url.here")
        installation.sendAllReports(completion: { filteredReports, completed, err in
            // Stuff to do when report sending is complete
        })

    }
}

进阶使用 KSCrash 分析 KSCrashInstallationStandard 都做了什么

虽然说 KSCrash 其实将框架解耦都挺不错了,但是我不是很理解的就是为啥没有一个将 Crash 日志输出写入文件的 KSCrashInstallation 实现,反而去做了一些不同服务端上传接口的适配(其中有好几家服务提供商已经嘎掉了)以及邮件发送 Crash 日志。那么如果想要实现上传到我们自定义的服务器(服务端有特殊鉴权时)或者仅写入本地文件(下次用户主动上传)时就得自己实现一个 KSCrashInstallation 了,不过 KSCrash 在文档(仅有一个 READMEA Brief Tour of the KSCrash Code and Architecture)这块其实写的不是很清楚,对于新手来说可能是会看着比较迷糊的,所以我们只能参考 Examples 和 KSCrashInstallationStandard 代码先来理解一下 KSCrash 的调用流程是什么样的,以及后续如何实现一个 KSCrashInstallationFile 将日志记录到指定的本地文件。

Swift 集成 KSCrash IOS 崩溃跟踪并扩展输出 Crash 日志到指定文件-天真的小窝

通过代码我们能够看到 KSCrash 主要分为 5 个大的部分(当前版本为 v1.17.2)分别是 KSCrashCoreKSCrashFiltersKSCrashInstallationsKSCrashRecordingKSCrashSinks

其中 KSCrashCore 目前仅一些头文件的定义和隐私权限声明文件,基础的日志记录部分基本上是在 KSCrashRecording 以及其一些工具模块中实现的,也就是说我们实现 KSCrashInstallationFile 需要关注的部分就是剩下的 KSCrashFilters、KSCrashInstallations、KSCrashSinks 三个部分了。

那我们就从 KSCrashInstallationStandard.sharedInstance 调用开始逐步抽丝剥茧(如果不想了解细节的小伙伴可以直接略过这章,后一章中有最终代码),我们一起看一下 KSCrashInstallationStandard.m 做了啥

#import "KSCrashInstallationStandard.h"
#import "KSCrashInstallation+Private.h"
#import "KSCrashReportSinkStandard.h"
#import "KSCrashReportFilterBasic.h"


@implementation KSCrashInstallationStandard

@synthesize url = _url;

+ (instancetype) sharedInstance
{
    static KSCrashInstallationStandard *sharedInstance = nil;
    static dispatch_once_t onceToken;
    
    dispatch_once(&onceToken, ^{
        sharedInstance = [[KSCrashInstallationStandard alloc] init];
    });
    return sharedInstance;
}

- (id) init
{
    return [super initWithRequiredProperties:[NSArray arrayWithObjects: @"url", nil]];
}

- (id<KSCrashReportFilter>) sink
{
    KSCrashReportSinkStandard* sink = [KSCrashReportSinkStandard sinkWithURL:self.url];
    return [KSCrashReportFilterPipeline filterWithFilters:[sink defaultCrashReportFilterSet], nil];
}

@end

KSCrashInstallationStandard 继承自 KSCrashInstallation,首先实现了一个单例 sharedInstance,然后对象中有一个 url 负责保存服务端地址,并在 sink 函数中返回了一个 KSCrashReportFilterPipeline 对象,而 KSCrashReportFilterPipeline 则是继承自 KSCrashReportFilter 的一个多 Filter 对象链式调用的实现,而构造 KSCrashReportFilterPipeline 传入的 KSCrashReportFilter 对象是由 KSCrashReportSinkStandard 对象的 defaultCrashReportFilterSet 函数获取到的。

那么关键肯定是在 KSCrashReportSinkStandard 中,我们看看他又做了什么事情。

#import "KSCrashReportSinkStandard.h"

#import "KSHTTPMultipartPostBody.h"
#import "KSHTTPRequestSender.h"
#import "NSData+KSGZip.h"
#import "KSJSONCodecObjC.h"
#import "KSReachabilityKSCrash.h"
#import "NSError+SimpleConstructor.h"

//#define KSLogger_LocalLevel TRACE
#import "KSLogger.h"


@interface KSCrashReportSinkStandard ()

@property(nonatomic,readwrite,retain) NSURL* url;

@property(nonatomic,readwrite,retain) KSReachableOperationKSCrash* reachableOperation;


@end


@implementation KSCrashReportSinkStandard

@synthesize url = _url;
@synthesize reachableOperation = _reachableOperation;

+ (KSCrashReportSinkStandard*) sinkWithURL:(NSURL*) url
{
    return [[self alloc] initWithURL:url];
}

- (id) initWithURL:(NSURL*) url
{
    if((self = [super init]))
    {
        self.url = url;
    }
    return self;
}

- (id <KSCrashReportFilter>) defaultCrashReportFilterSet
{
    return self;
}

- (void) filterReports:(NSArray*) reports
          onCompletion:(KSCrashReportFilterCompletion) onCompletion
{
    NSError* error = nil;
    NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:self.url
                                                           cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
                                                       timeoutInterval:15];
    KSHTTPMultipartPostBody* body = [KSHTTPMultipartPostBody body];
    NSData* jsonData = [KSJSONCodec encode:reports
                                   options:KSJSONEncodeOptionSorted
                                     error:&error];
    if(jsonData == nil)
    {
        kscrash_callCompletion(onCompletion, reports, NO, error);
        return;
    }

    [body appendData:jsonData
                name:@"reports"
         contentType:@"application/json"
            filename:@"reports.json"];
    // TODO: Disabled gzip compression until support is added server side,
    // and I've fixed a bug in appendUTF8String.
//    [body appendUTF8String:@"json"
//                      name:@"encoding"
//               contentType:@"string"
//                  filename:nil];

    request.HTTPMethod = @"POST";
    request.HTTPBody = [body data];
    [request setValue:body.contentType forHTTPHeaderField:@"Content-Type"];
    [request setValue:@"KSCrashReporter" forHTTPHeaderField:@"User-Agent"];

//    [request setHTTPBody:[[body data] gzippedWithError:nil]];
//    [request setValue:@"gzip" forHTTPHeaderField:@"Content-Encoding"];

    self.reachableOperation = [KSReachableOperationKSCrash operationWithHost:[self.url host]
                                                                   allowWWAN:YES
                                                                       block:^
    {
        [[KSHTTPRequestSender sender] sendRequest:request
                                        onSuccess:^(__unused NSHTTPURLResponse* response, __unused NSData* data)
         {
             kscrash_callCompletion(onCompletion, reports, YES, nil);
         } onFailure:^(NSHTTPURLResponse* response, NSData* data)
         {
             NSString* text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
             kscrash_callCompletion(onCompletion, reports, NO,
                                    [NSError errorWithDomain:[[self class] description]
                                                        code:response.statusCode
                                                    userInfo:[NSDictionary dictionaryWithObject:text
                                                                                         forKey:NSLocalizedDescriptionKey]
                                     ]);
         } onError:^(NSError* error2)
         {
             kscrash_callCompletion(onCompletion, reports, NO, error2);
         }];
    }];
}

@end

在 KSCrashReportSinkStandard 头文件中我们能看到 KSCrashReportSinkStandard 是继承于 KSCrashReportFilter 并且在 filterReports 函数中实现了将 Crash 日志 JSON 格式数据通过 POST 请求发送到服务端后调用 kscrash_callCompletion 函数返回调用结果。

总结,KSCrashReportSinkStandard 是 KSCrashReportFilter 的一个具体实现,KSCrashInstallationStandard 则是一个对 KSCrashReportSinkStandard 实例单例实现的封装,KSCrash 利用对 KSCrashReportFilter 的链式调用实现日志的筛选和格式化或者上传到服务端(发送邮件)等具体业务逻辑。

将 KSCrash 日志输出至文件,自定义 Installation 实现 KSCrashInstallationFile

接下来我们参考 KSCrashInstallationStandard 的实现,尝试使用 Swift 实现一个 KSCrashInstallationFile 将日志输出写入到指定文件中。

首先我们创建一个 KSCrashReportSinkFile 类,在 filterReports 函数中实现将 Crash 写入文件。

import Foundation
import KSCrash_Reporting_Filters

public class KSCrashReportSinkFile: KSCrashReportFilterPipeline {
    // 输出日志目录
    public var filePath: String?

    init(path: String?) {
        filePath = path
        super.init()
    }

    // 获取输出文件路径
    func outFilePath() -> String {
        if let filePath {
            // 输出到用户指定文件中
            return filePath
        }
        var tmpFileName = "ios_client_crash.json" // 临时文件名
        return NSTemporaryDirectory() + "/" + tmpFileName
    }

    override public func filterReports(_ reports: [Any]!, onCompletion: KSCrashReportFilterCompletion!) {
        guard let report = reports.first else {
            print("KSCrashReportSinkFile error report is nil")
            return
        }
        do {
            let filePath = outFilePath()
            // 处理文件路径
            if var fileURL = URL(string: filePath) {
                fileURL.deleteLastPathComponent()
                let dirPath = fileURL.path
                if !FileManager.default.fileExists(atPath: dirPath) {
                    try FileManager.default.createDirectory(atPath: dirPath, withIntermediateDirectories: true)
                }
            }

            let reportObj = try KSJSONCodec.encode(report, options: KSJSONEncodeOptionSorted)
            try String(data: reportObj, encoding: .utf8)?.write(toFile: filePath, atomically: false, encoding: .utf8)
        } catch {
            print("KSCrashReportSinkFile error \(error)")
        }
        // print("KSCrashReportSinkFile reports:\(report)")
        kscrash_callCompletion(onCompletion, reports, true, nil)
    }
}

以上代码中定义了 KSCrashReportSinkFile 类,继承自 KSCrashReportFilterPipeline 并重写 filterReports 函数,在函数中获取 reports 并使用 KSJSONCodec 将 report 格式化为 JSON 字符串,最后将 Crash JSON 字符串写入文件中。

紧接着,我们定义 KSCrashInstallationFile 继承于 KSCrashInstallationConsole 实现一个单例 sharedInstance 调用。这里需要注意的是,由于 KSCrashReportFilterPipeline 的初始化函数被默认定义为私有了,所以我们无法通过 KSCrashReportFilterPipeline 来添加 KSCrashReportFilter,但是后面我发现 KSCrashInstallationConsole 有一个 addPreFilter 函数,于是可通过 addPreFilter 函数来将 KSCrashReportSinkFile 实例添加到 KSCrash 中。

import Foundation
import KSCrash_Installations

public struct KSCrashInstallationFileOpts {
    public var outFilePath: String?
    public var reportStyle: KSAppleReportStyle?

    public init(outFilePath: String? = nil, reportStyle: KSAppleReportStyle? = nil) {
        self.outFilePath = outFilePath
        self.reportStyle = reportStyle
    }
}

public class KSCrashInstallationFile: KSCrashInstallationConsole {
    private static var shared: KSCrashInstallationFile?

    static var sharedInstance: KSCrashInstallationFile? = {
        if KSCrashInstallationFile.shared == nil {
            KSCrashInstallationFile.shared = KSCrashInstallationFile(opts: KSCrashInstallationFileOpts())
        }
        return KSCrashInstallationFile.shared
    }()

    // 输出日志目录
    public var options: KSCrashInstallationFileOpts

    public init(opts: KSCrashInstallationFileOpts) {
        options = opts
        super.init(requiredProperties: nil)
    }

    override public func install() {
        var filters: [KSCrashReportFilter] = []
        if let style = options.reportStyle {
            // FIXME: KSCrashReportFilterAppleFmt 格式输出待实现
            // reportStyle: KSAppleReportStyleSymbolicated
            filters.append(KSCrashReportFilterAppleFmt(reportStyle: style))
        }
        filters.append(KSCrashReportSinkFile(path: options.outFilePath))

        // addPreFilter(KSCrashReportFilterPipelineRef(filters: filters))
        for item in filters {
            addPreFilter(item)
        }

        super.install()
    }
}

最后,我们也是可以按照文档中的调用方法在 didFinishLaunchingWithOptions 函数中进行初始化就可以了。

import KSCrash_Installations

@main
class AppDelegate: UIApplicationDelegate {
    func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        let path = NSTemporaryDirectory() + "ios_client_crash.json"
        let ksCrash = KSCrashInstallationFile(opts: .init(outFilePath: path))
        ksCrash.install()
        ksCrash.sendAllReports { _, _, _ in
            print("tzmax: initKSCrash sendAllReportsWithCompletion")
        }

    }
}

以上全部示例代码都提交到 GitHub 仓库了,大家可以参考 https://github.com/PBK-B/ios-sample-project/tree/master/swift-kscrash-installation-file