开发过程中,调试必不可少,而日志则是一个重要的调试信息。当直接运行代码进行调试时,可以在Xcode控制台实时看到日志信息。然而当脱离了Xcode控制台,比如,安装到手机上时,这时我们该如何去查看日志呢?
其实可以把日志写入到一个文件中,然后通过文件查看日志信息。
把日志写入文件,主要是利用C语言的freopen()
函数进行重定向,将写往stdout
、stderr
的内容重定向到我们指定的文件中去,代码如下:
// _logFilePath: 日志文件路径
freopen([_logFilePath cStringUsingEncoding:NSASCIIStringEncoding], "a+", stdout);
freopen([_logFilePath cStringUsingEncoding:NSASCIIStringEncoding], "a+", stderr);
一旦执行了freopen()
函数,那么之后的NSLog()
函数输出的日志信息将不会再在Xcode控制台中显示,而是直接输出到日志文件中去了。
1. NSLog日志导出功能集成
根据上面使用freopen()
进行重定向的原理,然后结合iOS系统的分享弹窗UIActivityViewController
,即可完成日志文件导出的功能。
举例:当测试人员在某个页面发现一个错误或问题时,如果开发人员不能简单的通过他们描述的现象定位到问题的话,那么就需要开发人员自己去复现问题,然后分析代码以定位问题了。一般开发人员可以通过在代码中添加一些NSLog,把想要了解的信息通过Xcode控制台打印出来,然后去分析。
如果问题是必现的,那还好,复现问题然后就能进行分析、解决。
那如果问题是偶现的呢?开发人员不一定能复现问题,那就需要测试人员配合,进行多次测试了。测试人员如何在复现了问题时能及时的把日志给到开发人员呢?这时就可以在页面中添加日志导出功能了。添加该功能后,当测试人员再次复现问题时就可以立即导出日志文件发送给开发人员了。
若要在一个控制器中实现日志导出功能,则关键代码如下:
#import "ViewController.h"
#import "BWLogExportManager.h"
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// 开始日志记录
[BWLogExportManagerShared addExportButtonInViewController:self];
[BWLogExportManagerShared startLogRecordWithXcodeEnable:YES];
[self setupUI];
}
2. 实现效果
点击右上角 “Log Export” 按钮,弹出系统分享弹窗。然后选择将日志文件通过Mail、QQ等方式分享或直接保存到手机中。
3. BWLogExportManager 工具类的代码
不想拷贝下面代码的,去下载 👉👉👉👉: LogExportDemo
BWLogExportManager.h
//
// BWLogExportManager.h
// LogExportDemo
//
// Created by wangzhi on 2020/9/9.
// Copyright © 2020 BTStudio. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#define BWLogExportManagerShared [BWLogExportManager shared]
NS_ASSUME_NONNULL_BEGIN
/// 日志导出管理器
@interface BWLogExportManager : NSObject
/// Log日志文件名称 (默认格式为: 2020-01-01_08-59-59.log)
///
/// - 文件名称可设置为: xxx.log / xxx.txt, 建议设置为: xxx.log
///
/// - 提示: 需要在 开始日志记录 前设置文件名称,否则无效
@property (nonatomic, copy) NSString *logFileName;
/// 日志导出按钮
@property (nonatomic, strong) UIButton *logExportButton;
/// 单例
+ (instancetype)shared;
#pragma mark - 日志记录
/// 开始日志记录
/// @param xcodeEnable 连接Xcode运行时,是否记录日志
- (void)startLogRecordWithXcodeEnable:(BOOL)xcodeEnable;
/// 开始日志记录 (连接Xcode运行时,不记录日志)
- (void)startLogRecord;
/// 停止日志记录
/// @param remove 是否移除日志导出按钮
- (void)stopLogRecordWithExportButtonRemove:(BOOL)remove;
/// 停止日志记录
- (void)stopLogRecord;
/// 清除日志缓存文件
- (void)clearLogCaches;
// MARK: UI
/// 添加日志导出按钮到控制器右上角 (rightBarButtonItem)
/// @param viewController 控制器
- (void)addExportButtonInViewController:(UIViewController *)viewController;
/// 移除日志导出按钮
/// @param viewController 按钮所在控制器
- (void)removeExportButtonInViewController:(UIViewController *)viewController;
/// 通过系统的分享功能导出Log日志文件
/// @param viewController 弹出系统分享弹窗的控制器
- (void)exportLogFileInViewController:(UIViewController *)viewController;
/// 通过系统的分享功能导出Log日志文件
- (void)exportLogFile;
@end
NS_ASSUME_NONNULL_END
BWLogExportManager.m
//
// BWLogExportManager.m
// LogExportDemo
//
// Created by wangzhi on 2020/9/9.
// Copyright © 2020 BTStudio. All rights reserved.
//
#import "BWLogExportManager.h"
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0
#import "SceneDelegate.h"
#else
#endif
@interface BWLogExportManager () {
NSString *_defaultLogFilePath;
// FILE *_logFile;
}
/// Log文件夹路径
@property (nonatomic, copy) NSString *logFolderPath;
/// Log日志文件路径
@property (nonatomic, copy, readonly) NSString *logFilePath;
/// 日志导出按钮所在控制器
@property (nonatomic, strong) UIViewController *viewController;
@end
@implementation BWLogExportManager
/// 单例
+ (instancetype)shared {
static BWLogExportManager *instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}
- (instancetype)init {
if (self = [super init]) {
// 创建 BWLog 文件夹
_logFolderPath = [BWLogExportManager createFolderAtPath:[BWLogExportManager getDocumentPath] folderName:@"BWLog"];
// 设置Log日志文件的默认名称 (默认使用时间作为文件名称)
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
[dateFormatter setDateFormat:@"yyyy-MM-dd_HH-mm-ss"];
NSString *time = [dateFormatter stringFromDate:[NSDate date]];
_logFileName = [NSString stringWithFormat:@"%@.log", time];
// Log日志文件路径
_defaultLogFilePath = [_logFolderPath stringByAppendingPathComponent:_logFileName];
_logFilePath = _defaultLogFilePath;
// _logFile = NULL;
}
return self;
}
#pragma mark - Getters
- (UIButton *)logExportButton {
if (!_logExportButton) {
_logExportButton = [UIButton buttonWithType:UIButtonTypeSystem];
_logExportButton.frame = CGRectMake(0, 0, 120, 44);
_logExportButton.titleLabel.font = [UIFont systemFontOfSize:14];
_logExportButton.contentHorizontalAlignment = UIControlContentHorizontalAlignmentRight;
[_logExportButton setTitle:NSLocalizedString(@"Log Export", nil) forState:UIControlStateNormal];
[_logExportButton setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
[_logExportButton addTarget:self action:@selector(didClickLogExportButton) forControlEvents:UIControlEventTouchUpInside];
}
return _logExportButton;
}
#pragma mark - Setters
- (void)setLogFileName:(NSString *)logFileName {
_logFileName = logFileName;
// 拼接Log日志文件路径
_logFilePath = [_logFolderPath stringByAppendingPathComponent:_logFileName];
}
#pragma mark - Events
/// 日志记录导出事件
- (void)didClickLogExportButton {
[self exportLogFile];
}
#pragma mark - Public
/// 开始日志记录
/// @param xcodeEnable 连接Xcode运行时,是否记录日志
- (void)startLogRecordWithXcodeEnable:(BOOL)xcodeEnable {
// 1. isatty(int) : 该函数主要功能是检查设备类型,判断文件描述符是否是为终端机。
//
// 2. NSLog输出的日志内容,最终都是通过STDERR_FILENO句柄来记录的,所以可以考虑对其进行重定向
// (当然,重定向之后就无法在控制台看到日志打印了)。
//
// 3. 利用C语言的freopen函数进行重定向,将写往stderr的内容重定向到我们指定的文件中去。
// FILE *freopen(const char *__restrict, const char *__restrict, FILE *__restrict);
// 第一个参数:重定向到目标文件的路径
// 第二个参数:文件的打开模式,r/w/a/+等
// 第三个参数:被打开的文件名,通常使用标准流文件(stdin/stdout/stderr)
// 1. 删除之前的Log日志文件
[BWLogExportManager deleteFileAtPath:_logFilePath];
// 2. 将日志信息写入到Log日志文件中
if (xcodeEnable) {
// 记录原始的输出流并存储
[BWLogExportManager record_STDOUT_STDERR_FILENO];
// 利用freopen进行重定向
freopen([_logFilePath cStringUsingEncoding:NSASCIIStringEncoding], "a+", stdout);
freopen([_logFilePath cStringUsingEncoding:NSASCIIStringEncoding], "a+", stderr);
} else {
if (isatty(STDOUT_FILENO)) { // 如果连接Xcode运行,则不输出到文件中
} else {
// 记录原始的输出流并存储
[BWLogExportManager record_STDOUT_STDERR_FILENO];
// 利用freopen进行重定向
freopen([_logFilePath cStringUsingEncoding:NSASCIIStringEncoding], "a+", stdout);
freopen([_logFilePath cStringUsingEncoding:NSASCIIStringEncoding], "a+", stderr);
}
}
}
/// 开始日志记录 (连接Xcode运行时,不记录日志)
- (void)startLogRecord {
[self startLogRecordWithXcodeEnable:NO];
}
/// 停止日志记录
/// @param remove 是否移除日志导出按钮
- (void)stopLogRecordWithExportButtonRemove:(BOOL)remove {
// 恢复重定向
[BWLogExportManager recover_STDOUT_STDERR_FILENO];
// fclose(_logFile);
// _logFile = NULL;
if (remove) {
[self removeExportButtonInViewController:self.viewController];
}
}
/// 停止日志记录
- (void)stopLogRecord {
[self stopLogRecordWithExportButtonRemove:NO];
}
/// 清除日志缓存文件
- (void)clearLogCaches {
NSString *folderPath = BWLogExportManagerShared.logFolderPath;
[BWLogExportManager deleteFolderAtPath:folderPath];
}
// MARK: UI
/// 添加日志导出按钮到控制器右上角 (rightBarButtonItem)
/// @param viewController 控制器
- (void)addExportButtonInViewController:(UIViewController *)viewController {
if (!viewController) {
return;
}
self.viewController = viewController;
dispatch_async(dispatch_get_main_queue(), ^{
UIBarButtonItem *rightItem = [[UIBarButtonItem alloc] initWithCustomView:self.logExportButton];
viewController.navigationItem.rightBarButtonItem = rightItem;
});
}
/// 移除日志导出按钮
/// @param viewController 按钮所在控制器
- (void)removeExportButtonInViewController:(UIViewController *)viewController {
if (!viewController) {
return;
}
if (viewController == self.viewController) {
dispatch_async(dispatch_get_main_queue(), ^{
self.logExportButton.hidden = YES;
viewController.navigationItem.rightBarButtonItem = nil;
});
}
}
/// 通过系统的分享功能导出Log日志文件
/// @param viewController 弹出系统分享弹窗的控制器
- (void)exportLogFileInViewController:(UIViewController *)viewController {
// 1. 获取文件路径
NSURL *fileURL = nil;
// 判断文件是否存在
if ([BWLogExportManager existsFileAtPath:self.logFilePath]) {
fileURL = [NSURL fileURLWithPath:self.logFilePath];
} else {
fileURL = [NSURL fileURLWithPath:_defaultLogFilePath];
}
// 2. 弹出系统分享控制器以导出文件
dispatch_async(dispatch_get_main_queue(), ^{
NSArray *itemsArr = @[fileURL];
UIActivityViewController *activityViewController = [[UIActivityViewController alloc] initWithActivityItems:itemsArr applicationActivities:nil];
// 适配iPad
UIPopoverPresentationController *popVC = activityViewController.popoverPresentationController;
popVC.sourceView = viewController.view;
[viewController presentViewController:activityViewController animated:YES completion:nil];
});
}
/// 通过系统的分享功能导出Log日志文件
- (void)exportLogFile {
UIWindow *window = [BWLogExportManager getCurrentWindow];
[self exportLogFileInViewController:window.rootViewController];
}
#pragma mark - Tool Methods
/// 获取应用程序当前窗口
+ (UIWindow *)getCurrentWindow {
UIWindow *window = UIApplication.sharedApplication.keyWindow;
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0
if (@available(iOS 13.0, *)) {
NSSet *scenes = UIApplication.sharedApplication.connectedScenes;
for (UIScene *scene in scenes) {
if (scene.activationState == UISceneActivationStateForegroundActive ||
scene.activationState == UISceneActivationStateForegroundInactive) {
SceneDelegate *delegate = (SceneDelegate *)scene.delegate;
window = delegate.window;
break;
}
}
}
#else
#endif
return window;
}
// MARK: 文件相关操作
/// 获取 Document 路径
+ (NSString *)getDocumentPath {
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
return [paths objectAtIndex:0];
}
/// 创建指定路径的文件夹
/// @param path 指定的路径
/// @param name 文件夹名称
+ (NSString *)createFolderAtPath:(NSString *)path folderName:(NSString *)name {
// 1. 拼接文件夹路径
NSString *folderPath = [NSString stringWithFormat:@"%@/%@", path, name];
// 2. 判断文件夹是否存在
if ([self existsFolderAtPath:folderPath]) {
return folderPath; // 文件夹已存在,则直接返回文件夹路径
}
// 3. 文件夹不存在或者不是文件夹,则创建文件夹
NSFileManager *fileManager = [NSFileManager defaultManager];
NSError *error = nil;
BOOL success = [fileManager createDirectoryAtPath:folderPath withIntermediateDirectories:YES attributes:nil error:&error];
if (!success || error) { // 创建文件夹失败
NSLog(@"create folder error: %@", error.localizedDescription);
return nil;
}
// 创建文件夹成功,返回文件夹路径
return folderPath;
}
/// 判断文件是否存在
/// @param filePath 文件路径
/// @param isDirectory 是否为目录
+ (BOOL)existsFileAtPath:(NSString *)filePath isDirectory:(BOOL)isDirectory {
NSFileManager *fileManager = [NSFileManager defaultManager];
BOOL exists = [fileManager fileExistsAtPath:filePath isDirectory:&isDirectory];
return exists;
}
/// 判断文件是否存在
/// @param filePath 文件路径
+ (BOOL)existsFileAtPath:(NSString *)filePath {
return [self existsFileAtPath:filePath isDirectory:NO];
}
/// 判断文件夹是否存在
/// @param folderPath 文件夹路径
+ (BOOL)existsFolderAtPath:(NSString *)folderPath {
return [self existsFileAtPath:folderPath isDirectory:YES];
}
/// 删除文件
/// @param filePath 文件路径
/// @param isDirectory 是否为目录
+ (void)deleteFileAtPath:(NSString *)filePath isDirectory:(BOOL)isDirectory {
NSFileManager *fileManager = [NSFileManager defaultManager];
BOOL exists = [fileManager fileExistsAtPath:filePath isDirectory:&isDirectory];
if (exists) {
NSError *error = nil;
[fileManager removeItemAtPath:filePath error:nil];
if (error) {
NSLog(@"delete file error: %@", error.localizedDescription);
}
}
}
/// 删除文件
/// @param filePath 文件路径
+ (void)deleteFileAtPath:(NSString *)filePath {
[self deleteFileAtPath:filePath isDirectory:NO];
}
/// 删除文件夹
/// @param folderPath 文件夹路径
+ (void)deleteFolderAtPath:(NSString *)folderPath {
[self deleteFileAtPath:folderPath isDirectory:YES];
}
// MARK: STDOUT_FILENO & STDERR_FILENO
/// 记录原始的输出流
+ (void)record_STDOUT_STDERR_FILENO {
// 1. dup(int) : 该函数可以复制一个文件描述符。
// 传给该函数一个既有的描述符,它就会返回一个新的描述符,这个新的描述符是传给它的描述符的拷贝。
// 这意味着,这两个描述符共享同一个数据结构。
//
// 2. dup2(int, int) : 该函数允许调用者给定一个 源描述符 和 目标描述符,
// 函数成功返回时,目标描述符(第二个参数)将变成 源描述符(第一个参数)的复制品,
// 换句话说,两个文件描述符现在都指向同一个文件,并且是函数第一个参数指向的文件。
int origin_STDOUT_FILENO = dup(STDOUT_FILENO);
int origin_STDERR_FILENO = dup(STDERR_FILENO);
[[NSUserDefaults standardUserDefaults] setObject:[NSString stringWithFormat:@"%d", origin_STDOUT_FILENO] forKey:@"LOG_STDOUT_FILENO"];
[[NSUserDefaults standardUserDefaults] setObject:[NSString stringWithFormat:@"%d", origin_STDERR_FILENO] forKey:@"LOG_STDERR_FILENO"];
}
/// 恢复重定向
+ (void)recover_STDOUT_STDERR_FILENO {
int origin_STDOUT_FILENO = [[[NSUserDefaults standardUserDefaults] objectForKey:@"LOG_STDOUT_FILENO"] intValue];
int origin_STDERR_FILENO = [[[NSUserDefaults standardUserDefaults] objectForKey:@"LOG_STDERR_FILENO"] intValue];
dup2(origin_STDOUT_FILENO, STDOUT_FILENO);
dup2(origin_STDERR_FILENO, STDERR_FILENO);
}
@end