iOS开发调试技巧之NSLog日志导出

开发过程中,调试必不可少,而日志则是一个重要的调试信息。当直接运行代码进行调试时,可以在Xcode控制台实时看到日志信息。然而当脱离了Xcode控制台,比如,安装到手机上时,这时我们该如何去查看日志呢?

其实可以把日志写入到一个文件中,然后通过文件查看日志信息。

把日志写入文件,主要是利用C语言的freopen()函数进行重定向,将写往stdoutstderr的内容重定向到我们指定的文件中去,代码如下:

// _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等方式分享或直接保存到手机中。

1. 系统分享弹窗
2. 导出的日志文件内容

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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,922评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,591评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,546评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,467评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,553评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,580评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,588评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,334评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,780评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,092评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,270评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,925评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,573评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,194评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,437评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,154评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,127评论 2 352