淺談iOS日誌收集系統
在應用開發中為了給使用者更好操作體驗與精準資訊的展示,往往會收集一些使用者行為資訊,比如應用中使用者習慣的操作流程,相關頁面訪問次數,使用者個人資訊等等。大多數應用會整合第三方廠商提供的服務來統計這些資料,當然這樣做帶來的好處就是不用花費時間來寫相關日誌收集的功能,後臺也不用專門搭建相關的服務,而且第三方提供的工具也比較穩定。這讓我們能有更多的時間去開發產品主要業務功能上。
開發應用前期為了讓產品快速的推向市場與不斷的功能變更,往往不會花費時間在這些非主要業務的功能上。但當產品逐漸成熟進入到一個平臺期,如果我們想要獲取更多的使用者增長與留存,就要想辦法在針對自身的產品功能在不用的場景獲取更多的使用者行為來改進產品,獲取更多使用者資訊來精準針對不同使用者展示他們更感興趣的內容。而且這些資料也不希望儲存在其他廠商的伺服器上。這樣我們就不得不設計一套自己的日誌收集系統。本篇部落格只是講解自己對iOS日誌收集框架設計的一些思路與實現。
日誌分類
首先收集日誌根據日誌上傳的時機分為實時日誌與非實時日誌
實時日誌:收集結束後立刻上傳到伺服器。
非實時日誌:當日志累計到一定數量時上傳到伺服器。
對於實時日誌來說檔案大小實際上在一定範圍之內的。而非實時日誌由於是資訊累積一段時間後才會上傳到伺服器,所以對於非實時日誌我們需要控制日誌的大小不能讓日誌檔案無限增加。當然僅僅控制大小也是不行的,如果使用者使用次數很少而且我們的資料要一天統計一次那麼就會出現很多天都統計不到使用者的相關資料。所有我們也要控制非實時日誌的過期時間。如果日誌已經過期但大小沒有達到限制或者大小已經達到限制但沒有到達過期時間都是要上傳到伺服器的。
對於實時日誌與非實時日誌上傳伺服器來說都要有相關的錯誤處理。對於實時日誌來說,如果上傳失敗的話如果網路連線正常要嘗試重新上傳,當然這不是無限上傳的要有重試的次數,如果超出重試次數,那麼上傳失敗。對於非實時日誌來說也是一樣的處理邏輯。
更根據收集的資料來分類日誌可以分為事件日誌,使用者資訊日誌,崩潰日誌
事件日誌:也可以理解為使用者行為資料。當用戶使用某個功能或者進入某個頁面時會收集相關的資訊來統計每個使用者使用應用時習慣性操作,與偏好的功能還有頁面的PV等。當然也可以獲取到每個使用者在當前頁面所停留的時間。來判斷當前頁面是否能過吸引使用者。
使用者資訊日誌:這些資訊主要是為了針對不同的使用者來展示不同的功能或者內容,當然也包括當前使用機型的資訊有些基本資訊可以直接放在請求頭的UserAgent中而不需要單獨來統計。
崩潰日誌:應用崩潰資訊。
日誌收集框架
日誌收集主要用了兩個開源框架來實現:plcrashreporter
下面將主要介紹這兩個框架的使用。
CocoaLumberjack
關於CocoaLumberjack的相關說明與使用主要參考了這裡的文件。
首先是整合CocoaLumberjack主要有三個途徑,CocoaPods,Carthage,與手動整合。繼承這裡不多描述參考這裡。
配置CocoaLumberjack框架
- 將下面的程式碼新增到
.pch
檔案中
#define LOG_LEVEL_DEF ddLogLevel
#import <CocoaLumberjack/CocoaLumberjack.h>
在後面我們設定ddLogLevel的優先順序後,DDLog
巨集會通過LOG_LEVEL_DEF
來得知我們定義的優先順序。
- 在程式啟動時(一般在
applicationDidFinishLaunching
方法中)新增如下程式碼:
[DDLog addLogger:[DDASLLogger sharedInstance]];
[DDLog addLogger:[DDTTYLogger sharedInstance]];
這兩行程式碼添加了兩個loggers
到框架中,也就是說你的日誌會被髮送到Mac系統的Console.app
與Xcode的控制檯(與NSLog的效果一樣)中。
如果想把日誌寫入檔案中可以用下面的logger
DDFileLogger *fileLogger = [[DDFileLogger alloc] init];
fileLogger.rollingFrequency = 60 * 60 * 24; // 每個檔案超過24小時後會被新的日誌覆蓋
fileLogger.logFileManager.maximumNumberOfLogFiles = 7; //最多儲存7個日誌檔案
[DDLog addLogger:fileLogger];
我們主要使用DDFileLogger
這個來記錄相關事件,並且配合DDLogFileManager
來講相關的事件上傳到伺服器。在後面會介紹相關的用法。
可以設定全域性的日誌等級,並且可以在單獨的檔案中修改日誌等級。
在前面我們定義了LOG_LEVEL_DEF
巨集。在.pch
檔案定義ddLogLevel
常量
static const DDLogLevel ddLogLevel = DDLogLevelDebug;
上面定義了全域性的巨集同一為DDLogLevelDebug
如果想要在不同的檔案中更改日誌的等級只需要在使用DDLog前修改ddLogLevel
的值即可
。關於日誌輸出一共5個語句,當然也可以自己自定義日誌的語句
- DDLogError
- DDLogWarn
- DDLogInfo
- DDLogDebug
- DDLogVerbose
- 將
NSLog
語句轉換成DDLog
// Convert from this:
NSLog(@"Broken sprocket detected!");
NSLog(@"User selected file:%@ withSize:%u", filePath, fileSize);
// To this:
DDLogError(@"Broken sprocket detected!");
DDLogVerbose(@"User selected file:%@ withSize:%u", filePath, fileSize);
使用不同的日誌等級將會看到不同的日誌輸出
如果設定DDLogLevelError等級,那麼只會看到Error語句
如果設定DDLogLevelWarn等級,那麼會看到Error和Warn語句
如果設定DDLogLevelInfo等級,那麼會看到Error,Warn,Info語句
如果設定DDLogLevelDebug等級,那麼會看到Error,Warn,Info,Debug語句
如果設定DDLogLevelVerbose等級,會看到所有的DDLog語句
如果設定DDLogLevelOff等級,不會看到任何DDLog語句
CocoaLumberjack架構
這個框架的核心是DDLog
檔案,這個檔案提供不同的DDLog
巨集定義來替換系你的NSLog
語句,例如
DDLogWarn(@"Specified file does not exist");
這個語句實際上起到一個篩選的作用只有在相關的等級下才會輸出
if(LOG_WARN) /* Execute log statement */
而且當每輸出一個DDLog語句時,DDLog
都會將日誌訊息轉發給所有的前面註冊的logger
。
Loggers
logger是使用日誌訊息執行某些操作的類。CocoaLumberjack
帶有幾個少數的loggers(當然也可以自定義logger)。例如:DDASLLogger,DDASLLogger。前面已經說過可以將訊息傳送到系統和Xcode的控制檯。DDFileLogger可以將訊息寫入到檔案中。可以同時註冊多個logger。
當然也可以配置相關的Logger。例如DDFileLogger
自帶很多選項來供設定。每一個Logger都可以設定一個formatter
。formatter
主要用來格式化日誌資訊的。Formatters
Formatters可以允許你在Logger接受日誌前格式化日誌資訊。例如可以給日誌新增時間戳或者過濾日誌的一些不必要資訊。
Formatters可以單獨應用不同的loggers。可以為每個logger提供不同的Formatters。
自定義日誌訊息
每一個日誌訊息結構都有一個context
欄位。context
欄位是一個整數,它與日誌資訊一起傳遞給CocoaLumberjack框架。因此可以自由的定義這個欄位。
這個context欄位可以使用在很多方面,這裡列舉了幾個例子
- 有些應用模組化,有多個邏輯元件,如果每個元件都使用不同的context欄位,那麼如果使用這個框架很容易知道是哪一個模組列印的日誌。可以根據來自不同模組的日誌對日誌進行不同的格式化處理。
- 如果開發一個框架給其他人使用。希望別人在使用你的框架是可以很清楚的知道你的框架都做了什麼操作,這對發現和診斷問題很有幫助,如果使用這個框架那麼根據自定義的context欄位你很容易區分這個日誌訊息到底是來自於你開發的框架還是其他應用的日誌資訊。
日誌訊息結構
每一個日誌訊息都會轉換成DDLogMessage
物件
@interface DDLogMessage : NSObject <NSCopying>
{
// Direct accessors to be used only for performance
...
}
@property (readonly, nonatomic) NSString *message;
@property (readonly, nonatomic) DDLogLevel level;
@property (readonly, nonatomic) DDLogFlag flag;
@property (readonly, nonatomic) NSInteger context;
@property (readonly, nonatomic) NSString *file;
@property (readonly, nonatomic) NSString *fileName;
@property (readonly, nonatomic) NSString *function;
@property (readonly, nonatomic) NSUInteger line;
@property (readonly, nonatomic) id tag;
@property (readonly, nonatomic) DDLogMessageOptions options;
@property (readonly, nonatomic) NSDate *timestamp;
@property (readonly, nonatomic) NSString *threadID; // ID as it appears in NSLog calculated from the machThreadID
@property (readonly, nonatomic) NSString *threadName;
@property (readonly, nonatomic) NSString *queueLabel;
可以注意到context
這個欄位,預設的這個欄位沒個資訊都是0。
當然我們可以很容易自定義這個欄位
#define LSY_CONTEXT 100
#define LSYEventVerbose(frmt, ...) LOG_MAYBE(LOG_ASYNC_ENABLED, LOG_LEVEL_DEF, DDLogFlagVerbose, LSY_CONTEXT, nil, __PRETTY_FUNCTION__, frmt, ##__VA_ARGS__)
這樣我們可以得到一個日誌等級DDLogFlagVerbose
的LSYEventVerbose
巨集。使用這個巨集輸出的日誌得到的context
欄位值為100。
自定義Logger
Logger允許你直接將日誌訊息指向任何地方。
在DDLog
標頭檔案中定義了DDLoger協議。由三個強制方法組成
@protocol DDLogger <NSObject>
- (void)logMessage:(DDLogMessage *)logMessage;
/**
* Formatters may optionally be added to any logger.
*
* If no formatter is set, the logger simply logs the message as it is given in logMessage,
* or it may use its own built in formatting style.
**/
@property (nonatomic, strong) id <DDLogFormatter> logFormatter;
@optional
/**
* Since logging is asynchronous, adding and removing loggers is also asynchronous.
* In other words, the loggers are added and removed at appropriate times with regards to log messages.
*
* - Loggers will not receive log messages that were executed prior to when they were added.
* - Loggers will not receive log messages that were executed after they were removed.
*
* These methods are executed in the logging thread/queue.
* This is the same thread/queue that will execute every logMessage: invocation.
* Loggers may use these methods for thread synchronization or other setup/teardown tasks.
**/
- (void)didAddLogger;
- (void)willRemoveLogger;
/**
* Some loggers may buffer IO for optimization purposes.
* For example, a database logger may only save occasionaly as the disk IO is slow.
* In such loggers, this method should be implemented to flush any pending IO.
*
* This allows invocations of DDLog's flushLog method to be propogated to loggers that need it.
*
* Note that DDLog's flushLog method is invoked automatically when the application quits,
* and it may be also invoked manually by the developer prior to application crashes, or other such reasons.
**/
- (void)flush;
/**
* Each logger is executed concurrently with respect to the other loggers.
* Thus, a dedicated dispatch queue is used for each logger.
* Logger implementations may optionally choose to provide their own dispatch queue.
**/
@property (nonatomic, DISPATCH_QUEUE_REFERENCE_TYPE, readonly) dispatch_queue_t loggerQueue;
/**
* If the logger implementation does not choose to provide its own queue,
* one will automatically be created for it.
* The created queue will receive its name from this method.
* This may be helpful for debugging or profiling reasons.
**/
@property (nonatomic, readonly) NSString *loggerName;
@end
此外,如果自定義的logger繼承DDAbstractLogger
那麼會自動實現 (logFormatter
& setLogFormatter:
)這兩個強制的方法,因此實現一個logger很容易:
MyCustomLogger.h:
#import <Foundation/Foundation.h>
#import "DDLog.h"
@interface MyCustomLogger : DDAbstractLogger <DDLogger>
{
}
@end
MyCustomLogger.m
#import "MyCustomLogger.h"
@implementation MyCustomLogger
- (void)logMessage:(DDLogMessage *)logMessage {
NSString *logMsg = logMessage.message;
if (self->logFormatter)
logMsg = [self->logFormatter formatLogMessage:logMessage];
if (logMsg) {
// Write logMsg to wherever...
}
}
@end
logFormatter設計為logger的可選元件。 這是為了簡單。如果不需要格式化任何資訊則不需要新增logFormatter
這個屬性。並且logFormatter和logger之間是可重用的。 單個logFormatter可應用於多個logger。
自定義格式化訊息(Formatters)
格式化日誌訊息對於不同的logger來說是可選的屬性,如果設定了這個屬性,那麼在操作日誌訊息前可以對日誌訊息的結構做更改,還可以加上其他的一些資訊。
格式化日誌訊息還可以用來篩選日誌訊息,你可以自由的覺得哪些訊息需要被展示出來或者寫入檔案,哪些訊息需要過濾掉。
記住自定義格式化訊息(Formatters)可以單于的應用於Logger,因此可以對每個logger的訊息進行格式化或者篩選過濾。
使用
如果想自定義一個Formatters是很簡單的,至於要實現在標頭檔案 DDLog.h
中的DDLogFormatter
協議即可,這個協議僅僅有一個必須實現的方法:
@protocol DDLogFormatter <NSObject>
@required
/**
* Formatters may optionally be added to any logger.
* This allows for increased flexibility in the logging environment.
* For example, log messages for log files may be formatted differently than log messages for the console.
*
* For more information about formatters, see the "Custom Formatters" page:
* Documentation/CustomFormatters.md
*
* The formatter may also optionally filter the log message by returning nil,
* in which case the logger will not log the message.
**/
- (NSString *)formatLogMessage:(DDLogMessage *)logMessage;
@optional
// ...
@end
下面通過一個例項來說明如果自定義Formatters
MyCustomFormatter.h
#import <Foundation/Foundation.h>
#import "DDLog.h"
@interface MyCustomFormatter : NSObject <DDLogFormatter>
@end
MyCustomFormatter.m
#import "MyCustomFormatter.h"
@implementation MyCustomFormatter
- (NSString *)formatLogMessage:(DDLogMessage *)logMessage {
NSString *logLevel;
switch (logMessage->_flag) {
case DDLogFlagError : logLevel = @"E"; break;
case DDLogFlagWarning : logLevel = @"W"; break;
case DDLogFlagInfo : logLevel = @"I"; break;
case DDLogFlagDebug : logLevel = @"D"; break;
default : logLevel = @"V"; break;
}
return [NSString stringWithFormat:@"%@ | %@", logLevel, logMessage->_message];
}
@end
如果此時想要過濾掉這條日誌訊息那麼直接返回nil即可
然後將這個自定義的Formatters新增到Logger中:
[DDTTYLogger sharedInstance].logFormatter = [[MyCustomFormatter alloc] init];
日誌檔案管理
我們可以將寫入本地的日誌檔案壓縮或者上傳到伺服器
日誌檔案有兩個元件,一個元件是將日誌資訊寫入到本地檔案中,然後根據檔案大小或者過期時間來決定是否重新整理檔案,另一個元件是用來管理這些日誌檔案的。一旦日誌檔案寫入完成來決定這些檔案是應該被壓縮還是被上傳到伺服器,或者兩者都需要。
框架自帶的DDFileLogger
實現被分為兩個元件。DDFileLogger
是將日誌訊息寫入檔案的元件。而DDLogFileManager
是一個管理日誌檔案的協議,並決定檔案將要被重新整理時如何處理它。
首先看一下DDFileLogger
的初始化:
@interface DDFileLogger : NSObject <DDLogger>
...
- (instancetype)init;
- (instancetype)initWithLogFileManager:(id <DDLogFileManager>)logFileManager NS_DESIGNATED_INITIALIZER;
...
@end
預設初始化方法簡單的使用了DDLogFileManagerDefault
這個類。這個類只提供了刪除舊日誌檔案的方法。
還有一個初始化方法就需要傳入一個自定義的日誌檔案管理類。
使用
如果想使用DDFileLogger
,這個框架自帶將日誌寫入檔案的類,並且需要自定義一個檔案管理,那麼就需要實現DDLogFileManager
協議。當然,如果連Logger都是自定義的話那麼就不需要按照框架這樣分兩個元件去實現。這個前提是使用DDFileLogger
並想自定義檔案管理。
@protocol DDLogFileManager <NSObject>
@required
// Public properties
@property (readwrite, assign) NSUInteger maximumNumberOfLogFiles;
// Public methods
- (NSString *)logsDirectory;
- (NSArray *)unsortedLogFilePaths;
- (NSArray *)unsortedLogFileNames;
- (NSArray *)unsortedLogFileInfos;
- (NSArray *)sortedLogFilePaths;
- (NSArray *)sortedLogFileNames;
- (NSArray *)sortedLogFileInfos;
// Private methods (only to be used by DDFileLogger)
- (NSString *)createNewLogFile;
@optional
// Notifications from DDFileLogger
- (void)didArchiveLogFile:(NSString *)logFilePath;
- (void)didRollAndArchiveLogFile:(NSString *)logFilePath;
@end
如果自定義實現日誌檔案管理,那麼需要實現上面@required
的方法。當檔案需要重新整理時會通知@optional
的兩個方法。
可能對@required
的方法有些困惑,檢視DDFileLogger
的實現實際上只用到了sortedLogFileInfos
的方法來獲取當前操作檔案的資訊。如果自定義日誌檔案管理的話只需要實現sortedLogFileInfos
就可以了。但是如果在外部訪問這些屬性不發生錯誤那麼最好全部都實現。
簡單實現
下面我們將會通過程式碼,自定義一條訊息來過濾不需要的日誌,並且將相關日誌寫入檔案並上傳的伺服器。
在.pch檔案中,進行如下配置
#import "CocoaLumberjack.h"
static DDLogLevel ddLogLevel = DDLogLevelVerbose;
#define JHEVENT_CONTEXT 100
#define JHEventVerbose(frmt, ...) LOG_MAYBE(LOG_ASYNC_ENABLED, LOG_LEVEL_DEF, DDLogFlagVerbose, JHEVENT_CONTEXT, nil, __PRETTY_FUNCTION__, frmt, ##__VA_ARGS__)
上面定義了日誌輸出的優先順序為DDLogLevelDebug
。並且自定義了日誌輸出的訊息型別,CONTEXT
為100,用於篩選日誌訊息。
在程式啟動時
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[DDLog addLogger:[DDASLLogger sharedInstance]];
[DDLog addLogger:[DDTTYLogger sharedInstance]];
JHEventFileManager *fileManager = [[JHEventFileManager alloc] init]; //自定義日誌檔案管理
JHEventLogger *fileLogger = [[JHEventLogger alloc] initWithLogFileManager:fileManager]; //自定義檔案Logger
fileLogger.rollingFrequency = 60 * 60 * 24; // 有效期是24小時
fileLogger.logFileManager.maximumNumberOfLogFiles = 2; //最多檔案數量為2個
fileLogger.logFormatter = [[JHEventFormatter alloc] init]; //日誌訊息格式化
fileLogger.maximumFileSize = 1024*50; //每個檔案數量最大尺寸為50k
fileLogger.logFileManager.logFilesDiskQuota = 200*1024; //所有檔案的尺寸最大為200k
[DDLog addLogger:fileLogger];
return YES;
}
上面一共添加了三種Logger,通過DDLog
輸出的訊息會被這三種Logger
接收。上面使用時定義了幾個引數相互約束來控制日誌檔案的有效期和大小的。當單個檔案大於50k時會新建一個日誌檔案。當第二個檔案大於50k時會將最早的檔案刪除掉。當檔案有限期超過24小時或者所有檔案的尺寸大於200k時也會將最早的日誌檔案刪除掉。
JHEventFileManager
的實現如下:
JHEventFileManager.h
@interface JHEventFileManager : DDLogFileManagerDefault
@end
JHEventFileManager.m
@implementation JHEventFileManager
- (void)didArchiveLogFile:(NSString *)logFilePath
{
}
- (void)didRollAndArchiveLogFile:(NSString *)logFilePath
{
}
@end
這個類直接繼承框架自帶的DDLogFileManagerDefault
類,並沒有重寫一個實現<DDLogFileManager>
協議的新類。根據不同的業務可以參考DDLogFileManagerDefault
類,重新寫一個新的日誌檔案管理。
實現上面的方法主要當日志文件將要被重新整理刪除時會呼叫,此時我們可以獲取到這個檔案將檔案上傳到伺服器。
關於JHEventLogger
類的實現也是直接繼承系統的DDFileLogger
。
JHEventLogger.h
@interface JHEventLogger : DDFileLogger
@end
JHEventLogger.m
- (void)logMessage:(DDLogMessage *)logMessage {
[super logMessage:logMessage];
}
- (void)willLogMessage
{
[super willLogMessage];
}
- (void)didLogMessage
{
[super didLogMessage];
}
也可以直接使用DDFileLogger
類。這樣做可以在寫入日誌時獲取相關的通知,從而進行其他操作。
JHEventFormatter
用來篩選和格式化日誌資訊:
JHEventFormatter.h
@interface JHEventFormatter : NSObject <DDLogFormatter>
@end
JHEventFormatter.m
@implementation JHEventFormatter
- (NSString *)formatLogMessage:(DDLogMessage *)logMessage {
NSString *logLevel;
switch (logMessage->_flag) {
case DDLogFlagError : logLevel = @"E"; break;
case DDLogFlagWarning : logLevel = @"W"; break;
case DDLogFlagInfo : logLevel = @"I"; break;
case DDLogFlagDebug : logLevel = @"D"; break;
default : logLevel = @"V"; break;
}
if (logMessage.context == JHEVENT_CONTEXT) {
return [NSString stringWithFormat:@"%@ | %@", logLevel, logMessage->_message];
}
return nil;
}
@end
只有當我們訊息是由JHEventVerbose
巨集列印時才會將日誌寫入本地。
當我們使用時如下:
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
ddLogLevel = DDLogLevelVerbose;
}
- (IBAction)event_1:(id)sender {
DDLogError(@"error log");
}
- (IBAction)event_2:(id)sender {
DDLogVerbose(@"Verbose log");
}
- (IBAction)event_3:(id)sender {
JHEventVerbose(@"JHEventVerbose");
}
@end
我們在.pch檔案中定義了DDLog
的優先順序為DDLogLevelDebug
,此時DDLogVerbose
和JHEventVerbose
都不會有輸出,但是在viewDidLoad
方法裡我們針對這個檔案將優先順序改成DDLogLevelVerbose
後DDLogVerbose
與JHEventVerbose
都會正常輸出,其他檔案的優先順序還是DDLogLevelDebug
。
這三個輸出都會被DDASLLogger
,DDTTYLogger
,JHEventLogger
接收到,也就是我們會在Xcode控制檯與Mac上的控制檯看到這三個輸出。但是隻有JHEventVerbose
的輸出會儲存到本地,因為我們之前在格式化的時候通過context
欄位將前兩個資訊已經過濾掉了。
上面就是CocoaLumberjack
的簡單介紹與使用,更多的使用方法還是需要參考這裡的文件。
plcrashreporter
說明:有關plcrashreporter
的介紹大多數都是參考的plcrashreporter
開發團隊提供的文件,如果想看更多或者下載安裝plcrashreporter
的請移步這裡。
CrashReporter提供了一個用於iOS和Mac OS X的程序中崩潰報告框架,併為iOS提供了大部分崩潰報告服務,包括HockeyApp,Flurry和Crittercism。
特點
- 僅支援使用公開API/ABI的崩潰報告。
- 首次在2008年釋出,並用於成千上萬的應用程式。 PLCrashReporter已經看到了大量的使用者測試。
- 提供所有活動執行緒的呼叫棧。
- 最精準的可用堆疊展開,使用DWARF和Apple Compact Unwind框架資料
- 不妨礙lldb / gdb中的除錯
- 易於整合現有或定製的崩潰報告服務。
- 為崩潰的執行緒提供完整的暫存器狀態。
解碼崩潰報告
崩潰報告作為protobuf編碼訊息輸出,可以使用CrashReporter庫或任何Google Protobuf解碼器進行解碼。
除了內建庫解碼支援外,你可以使用附帶的plcrashutil二進位制檔案將崩潰報告轉換為蘋果標準的iPhone文字格式,這可傳遞給符號化工具
./bin/plcrashutil convert --format=iphone example_report.plcrash | symbolicatecrash
將來的釋出版本可能包括可重用的格式化程式,用於直接從手機輸出不同的格式
構建
構建一個可嵌入的framework
user@max:~/plcrashreporter-trunk> xcodebuild -configuration Release -target 'Disk Image'
這將在build/Release/PLCrashReporter-{version}.dmg中輸出一個新的版本,其中包含可嵌入的Mac OS X框架和iOS靜態框架。
PLCrashReporter介紹
Plausile CrashReporter實現了在iPhone和Mac OS X上程序中的崩潰報告。
支援以下功能:
- 實現程序內訊號處理。
- 不干擾gdb中的除錯
- 處理未被捕獲的Objective-C異常和致命訊號(SIGSEGV,SIGBUS等)。
- 提供所有活動執行緒(呼叫棧,暫存器drump)的完整執行緒狀態。
- 如果您的應用程式崩潰,將會寫入崩潰報告。 當應用程式下一次執行時,您可以檢查掛起的崩潰報告,並將報告提交到您自己的HTTP伺服器,傳送電子郵件,甚至在本地內部報告。
崩潰報告格式
崩潰日誌使用google protobuf解碼,也可以使用PLCrashReportAPI進行解碼。除此之外附帶的plcrashutil可以處理將二進位制崩潰報告轉換為符號相容的iPhone文字格式。
PLCrashReporter類列表與功能簡述
class | brief |
---|---|
PLCrashHostInfoVersion | major.minor.revision版本號 |
PLCrashProcessInfo | 提供訪問有關目標程序的基本資訊的方法 |
PLCrashReport | 提供PLCrashReporter框架生成的崩潰日誌的解碼 |
PLCrashReportApplicationInfo | 崩潰日誌應用程式資料 |
PLCrashReportBinaryImageInfo | 崩潰日誌二進位制影象資訊 |
PLCrashReporter | 崩潰記錄 |
PLCrashReporterCallbacks | 支援PLCrashReporter回撥,允許主機應用程式在發生崩潰之後在程式終止之前執行其他任務的回撥 |
PLCrashReporterConfig | 崩潰記錄配置 |
PLCrashReportExceptionInfo | 如果由於未被捕獲的Objective-C異常觸發崩潰,將會提供異常名稱和原因 |
PLCrashReportFileHeader | 崩潰日誌檔案頭格式 |
<PLCrashReportFormatter> |
崩潰報告格式接受PLCrashReport例項化,根據實現指定的協議進行格式化(如實現文字輸出支援),並返回結果 |
PLCrashReportMachExceptionInfo | 提供訪問異常型別和程式碼 |
PLCrashReportMachineInfo | 崩潰日誌主機架構資訊 |
PLCrashReportProcessInfo | 崩潰日誌程序資料 |
PLCrashReportProcessorInfo | 崩潰日誌程序記錄 |
PLCrashReportRegisterInfo | 崩潰日誌通用暫存器資訊 |
PLCrashReportSignalInfo | 提供對signal名稱和siganl程式碼的訪問 |
PLCrashReportStackFrameInfo | 崩潰日誌堆疊資訊 |
PLCrashReportSymbolInfo | 崩潰日誌符號資訊 |
PLCrashReportSystemInfo | 崩潰日誌系統資料 |
PLCrashReportTextFormatter | 將PLCrashReport資料格式化為可讀的文字 |
PLCrashReportThreadInfo | 崩潰日誌每個執行緒狀態資訊 |
更多詳細介紹請參考
iPhone使用例項
- (void) handleCrashReport {
PLCrashReporter *crashReporter = [PLCrashReporter sharedReporter];
NSData *crashData;
NSError *error;
// Try loading the crash report
crashData = [crashReporter loadPendingCrashReportDataAndReturnError: &error];
if (crashData == nil) {
NSLog(@"Could not load crash report: %@", error);
goto finish;
}
// We could send the report from here, but we'll just print out
// some debugging info instead
PLCrashReport *report = [[[PLCrashReport alloc] initWithData: crashData error: &error] autorelease];
if (report == nil) {
NSLog(@"Could not parse crash report");
goto finish;
}
NSLog(@"Crashed on %@", report.systemInfo.timestamp);
NSLog(@"Crashed with signal %@ (code %@, address=0x%" PRIx64 ")", report.signalInfo.name,
report.signalInfo.code, report.signalInfo.address);
// Purge the report
finish:
[crashReporter purgePendingCrashReport];
return;
}
- (void) applicationDidFinishLaunching: (UIApplication *) application {
PLCrashReporter *crashReporter = [PLCrashReporter sharedReporter];
NSError *error;
// Check if we previously crashed
if ([crashReporter hasPendingCrashReport])
[self handleCrashReport];
// Enable the Crash Reporter
if (![crashReporter enableCrashReporterAndReturnError: &error])
NSLog(@"Warning: Could not enable crash reporter: %@", error);
}
注意:在Xcode除錯模式下是捕獲不到異常的,包括真機除錯和模擬器除錯,此時需要斷開除錯模式後製造異常,捕獲後通過Xcode再次執行應用就可以檢視儲存在本地的異常記錄了
上面的寫法是PLCrashReporter
文件中給出的例項程式碼,通過hasPendingCrashReport
方法來判斷是否存在奔潰資訊,如果存在就會呼叫handleCrashReport
方法來處理。處理結束後會呼叫purgePendingCrashReport
方法來清除之前儲存的奔潰報告。如果我們想要把奔潰資訊上傳到伺服器那麼就會以下問題:
如果在上傳的過程過程中又出現了新的崩潰資訊,那麼舊的資訊就會被新的奔潰資訊所覆蓋丟失,這樣做只能本地儲存一份崩潰日誌。如果舊的奔潰日誌成功上傳到伺服器還好,如果因為網路原因沒有上傳成功,那麼此時再出現新的崩潰,老的資料就會丟失。
所以使用的時候還應該對上面的程式碼進行一下修改:
@implementation AppDelegate
static void save_crash_report (PLCrashReporter *reporter) {
NSFileManager *fm = [NSFileManager defaultManager];
NSError *error;
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *documentsDirectory = [paths objectAtIndex:0];
if (![fm createDirectoryAtPath: documentsDirectory withIntermediateDirectories: YES attributes:nil error: &error]) {
NSLog(@"Could not create documents directory: %@", error);
return;
}
NSData *data = [reporter loadPendingCrashReportDataAndReturnError: &error];
if (data == nil) {
NSLog(@"Failed to load crash report data: %@", error);
return;
}
NSString *outputPath = [documentsDirectory stringByAppendingPathComponent: [NSString stringWithFormat:@"demo_%f.plcrash",[NSDate date].timeIntervalSince1970]];
if (![data writeToFile: outputPath atomically: YES]) {
NSLog(@"Failed to write crash report");
}
else{
NSLog(@"Saved crash report to: %@", outputPath);
[reporter purgePendingCrashReport];
}
}
static void post_crash_callback (siginfo_t *info, ucontext_t *uap, void *context) {
// this is not async-safe, but this is a test implementation
NSLog(@"post crash callback: signo=%d, uap=%p, context=%p", info->si_signo, uap, context);
}
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
PLCrashReporter *crashReport = [PLCrashReporter sharedReporter];
NSError *error;
if ([crashReport hasPendingCrashReport]) {
[self handleCrashReport];
}
if (![crashReport enableCrashReporterAndReturnError:&error]) {
NSLog(@"Warning: Could not enable crash reporter: %@", error);
}
return YES;
}
-(void)handleCrashReport
{
PLCrashReporter *crashReporter = [PLCrashReporter sharedReporter];
save_crash_report(crashReporter);
PLCrashReporterCallbacks cb = {
.version = 0,
.context = (void *) 0xABABABAB,
.handleSignal = post_crash_callback
};
[crashReporter setCrashCallbacks: &cb];
}
@end
上面的程式碼每次有崩潰日誌時都會將日誌再備份一次到本地,以防日誌丟失,備份後將日誌上傳到伺服器,上傳成功後將備份的日誌刪除掉。如果失敗下次啟動時也可以檢查備份目錄有多少上傳失敗的檔案,然後根據情況重新上傳。上面的程式碼還添加了崩潰發生時的回撥,具體可以參照上面列表中介紹類的資訊PLCrashReporterCallbacks:支援PLCrashReporter回撥,允許主機應用程式在發生崩潰之後在程式終止之前執行其他任務的回撥
。
崩潰日誌解析
上面提到了崩潰日誌解析有幾種方式,這裡介紹使用附帶的plcrashutil
工具進行解析。現在最新的PLCrashReporter
釋出版本是1.2。下載這個版本在Tools
資料夾裡會看見plcrashutil
的可執行檔案。
我們製造幾次崩潰後會在上面程式碼儲存的目錄下發現如下檔案:
tupian
將其中一個崩潰檔案與plcrashutil
可執行檔案放在一個目錄下。並且在shell中cd到這個目錄後執行如下命令:
./plcrashutil convert --format=iphone demo_1494240181.851469.plcrash > app.crash
這條命令會將plcrash
檔案轉換成蘋果標準崩潰格式。
配置環境變數執行:
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
找到Xcode
自帶的符號化工具symbolicatecrash
在Xcode 8.3
中的位置如下
/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash
獲取應用的符號化檔案yourappname.app.dSYM
將symbolicatecrash
和yourappname.app.dSYM
放到與plcrashutil
相同目錄下:
./symbolicatecrash app.crash yourappname.app.dSYM > app.log
此時生成的app.log
檔案即是符號化解析後的檔案。