CJMethodLog 二:从监控还原APP运行的每一行代码说起

导语:

CJMethodLog 对于Objective-C中的任意类、任意方法,均可实时根据用户的操作行为,监控还原对应的函数调用日志,而且能够自定义记录当前函数的参数类型、返回类型、执行时间……


CJMethodLog

上一篇介绍了 Runtime 原理
CJMethodLog(一)Runtime原理:从监控还原APP运行的每一行代码说起
这里就来讲讲 CJMethodLog 的具体实现。

CJMethodLogSource.png

上图展示了CJMethodLog的文件结构,其中CJMethodLog.h CJMethodLog.m是核心部分

/**
 * 初始化类名监听配置
 * 注意!!!所有设置的hook类不能存在继承关系
 *
 * @param classNameList 需要hook的类名
 * @param options       日志选项
 * @param value         是否打印监听日志,(设置为YES,会输出方法监听的log信息,该值只在 DEBUG 环境有效)
 */
+ (void)forwardingClasses:(NSArray <NSString *>*)classNameList logOptions:(CJLogOptions)options logEnabled:(BOOL)value;


/**
 * 获取日志文件
 *
 * @param finishBlock 获取日志文件回调block
 */
+ (void)syncLogData:(SyncDataBlock)finishBlock;

/**
 * 删除日志数据
 */
+ (void)clearLogData;

CJMethodLog.h 暂时提供三个方法:初始化配置、获取日志文件、删除日志数据。

使用

main.m 文件中设置需要监听的类名配置,理论上任意时刻都可以重设监听配置,但不建议这么做!!因为每次重设监听配置都会修改监听类的方法链表(methodLists)中方法的IMP实现,随意修改可能会出现替换指定IMP的同时刚好调用了该IMP的实现,造成不可预知错误。另外在 main.m中初始化配置可以确保所有的hook类都生效,比如如果你hook的是 AppDelegate 类。

#import <UIKit/UIKit.h>
#import "AppDelegate.h"
#import "CJMethodLog.h"

int main(int argc, char * argv[]) {
    @autoreleasepool {
        /*
         * 利用消息转发,hook指定类的调用方法
         */
        [CJMethodLog forwardingClasses:@[
                                         @"AppDelegate",
                                         @"TestViewController"
                                         ]
                            logOptions:CJLogDefault
                            logEnabled:NO];
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

下图展示了hook TestViewController类之后的函数调用情况:

CJMethodLog.gif

日志格式说明:

- <TestViewController>  begin:  -clickManagerTest:
-- <TestViewController>  begin:  +managerTest
-- <TestViewController>  finish: +managerTest ; time=0.000110
- <TestViewController>  finish: -clickManagerTest: ; time=0.000416
  • 最开始的- 表示函数调用层级;
  • <TestViewController> 表示当前调用函数的类名;
  • begin: finish: 分别表示函数执行起始阶段(只会在设置了CJLogMethodTimer选项的时候出现);
  • -clickManagerTest: 表示执行的是实例方法,+managerTest 表示执行的是类方法;
  • time=0.000110 表示函数耗时
  • 之后会补充函数参数以及返回结果说明
日志数据

获取日志数据使用+ (void)syncLogData:(SyncDataBlock)finishBlock ,你可以根据需要获取。比如这里在app启动的时候获取,判断当数据量大于10*1024的时候上传服务器并删除客户端数据。

- (void)applicationDidBecomeActive:(UIApplication *)application {
    [CJMethodLog syncLogData:^void(NSData *logData) {
        NSLog(@"CJMethodLog: logData = %@",@([logData length]));
        if ([logData length] > 10*1024) {
            // TODO: 上传到服务器等自定义处理
            // 删除日志数据
            [CJMethodLog clearLogData];
        }
    }];
}
其他

CJLogger.h CJLogger.mm 是日志数据存储与获取的实现类,这是一个由OC和C++混编实现的类。
CJMethodLog+CJMessage.h 是处理一些通用设置的分类。


实现原理

CJMethodLog 借助 Runtime 的消息转发机制,在调用方法的时候主动触发消息转发,然后在 - (void)forwardInvocation:(NSInvocation *)anInvocation 方法中还原当前selector的实现,同时记录监控日志信息。下面是具体的实现流程图:

初始设置阶段:

CJMethodLog设置.png

方法执行阶段:

CJMethodLog执行.png

一些关键点

  1. 默认不 hook 系统类。CJMethodLog 理论上可以 hook 记录任意类、任意类的任意方法的执行日志,但实际上很少会这样做;我们关注的应该是基于具体的业务需求对应到代码层面是怎样的函数执行逻辑,而且这里的函数执行逻辑信息一般只需要到达app层级的逻辑实现就可以了,没必要更进一步去关注系统层级的实现。当然我也不介意你在初始阶段设置指定系统类或三方类,分析其内部的逻辑实现,然后做些坏坏的事😀。

    附:设置不 hook 系统类的另一个原因是,hook 某个类,准确的做法应该要将该类的方法、它的父类的方法、父类的父类的方法……全都 hook 上,这样才能完整的还原出它在整个生命周期内的函数调用日志。那如果将父类方法也 hook,这时就需要一个出口了,不然会导致继承链的判断过长。我的做法是判断当父类为系统类时则停止。
    其实 hook 父类方法部分还未实现,因为遇到了难题,具体后面会讲到

    判断是否为自定义类:

     BOOL inMainBundle(Class hookClass) {
         NSBundle *currentBundle = [NSBundle bundleForClass:hookClass];
         return [currentBundle.bundlePath hasPrefix:[NSBundle mainBundle].bundlePath];
     }
    
  2. 过滤属性的setter和getter方法。属性的setter和getter方法不hook,不然每调用一次 self. 语法就产生一条监控日志,造成太多没必要的干扰信息。注意默认过滤规则:getter方法(属性名),setter方法(setXXX:),其他自定义的setter和getter方法无法过滤。

     + (void)enumerateClassMethods:(Class)hookClass forwardMsg:(BOOL)forwardMsg logOptions:(CJLogOptions)options {
         
         NSString *hookClassName = NSStringFromClass(hookClass);
         // hookClass中已经被hook过的方法
         NSArray *hookClassMethodList = [_hookClassMethodDic objectForKey:hookClassName];
         NSMutableArray *methodList = [NSMutableArray arrayWithArray:hookClassMethodList];
         
         //属性的 setter 与 getter 方法不hook
         NSMutableArray *propertyMethodList = [NSMutableArray array];
         unsigned int propertyCount = 0;
         objc_property_t *properties = class_copyPropertyList(hookClass, &propertyCount);
         for (int i = 0; i < propertyCount; i++) {
             objc_property_t property = properties[i];
             // getter 方法
             NSString *propertyName = [[NSString alloc]initWithCString:property_getName(property) encoding:NSUTF8StringEncoding];
             [propertyMethodList addObject:propertyName];
             // setter 方法
             NSString *firstCharacter = [propertyName substringToIndex:1];
             firstCharacter = [firstCharacter uppercaseString];
             NSString *endCharacter = [propertyName substringFromIndex:1];
             NSMutableString *propertySetName = [[NSMutableString alloc]initWithString:@"set"];
             [propertySetName appendString:firstCharacter];
             [propertySetName appendString:endCharacter];
             [propertySetName appendString:@":"];
             [propertyMethodList addObject:propertySetName];
         }
         
         unsigned int outCount;
         Method *methods = class_copyMethodList(hookClass,&outCount);
         for (int i = 0; i < outCount; i ++) {
             // Method tempMethod = *(methods + i);
             Method tempMethod = methods[i];
             SEL selector = method_getName(tempMethod);
             
             BOOL needHook = YES;
             for (NSString *selStr in propertyMethodList) {
                 SEL propertySel = NSSelectorFromString(selStr);
                 if (sel_isEqual(selector, propertySel)) {
                     needHook = NO;
                     break;
                 }
             }
             
             if (needHook) {
                 if (forwardMsg) {
                     /*
                      * 方案一:利用消息转发,hook forwardInvocation: 方法
                      */
                     BOOL canHook = enableHook(tempMethod);
                     if (canHook) {
                         forwardInvocationReplaceMethod(hookClass, selector, options);
                     }
                 }else{
     //                char *returnType = method_copyReturnType(tempMethod);
     //                /*
     //                 * 方案二:hook每一个方法(未实现)
     //                 */
     //                cjlHookMethod(hookClass, selector, returnType);
     //                free(returnType);
                 }
                 
                 [methodList addObject:NSStringFromSelector(selector)];
             }
             
         }
         free(methods);
         
         [_hookedClassList addObject:hookClassName];
         [_hookClassMethodDic setObject:methodList forKey:hookClassName];
     }
    
  3. 一些系统方法不应该 hook。具体如下:

     @[  /*UIViewController的:*/
         @".cxx_destruct",
         @"dealloc",
         @"_isDeallocating",
         @"release",
         @"autorelease",
         @"retain",
         @"Retain",
         @"_tryRetain",
         @"copy",
    
         /*UIView的:*/
         @"nsis_descriptionOfVariable:",
         
         /*NSObject的:*/
         @"respondsToSelector:",
         @"class",
         @"allowsWeakReference",
         @"retainWeakReference",
         @"init",
         @"resolveInstanceMethod:",
         @"resolveClassMethod:",
         @"forwardingTargetForSelector:",
         @"methodSignatureForSelector:",
         @"forwardInvocation:",
         @"doesNotRecognizeSelector:",
         @"description",
         @"debugDescription",
         @"self",
         @"lockFocus",
         @"lockFocusIfCanDraw",
         @"lockFocusIfCanDraw"
     ];  
    
  4. CACurrentMediaTime() 。如果选择了日志选项CJLogMethodTimer,那么在计算函数执行时间时采用 CACurrentMediaTime() 计算,CACurrentMediaTime()是基于内建时钟的,能够更精确更原子化地测量,并且不会因为外部时间变化而变化(例如时区变化、夏时制、秒突变等),可以最小化的减少性能损耗。

  5. mmap。日志数据采用 mmap 内存映射的方式存储,具体请看 CJLogger.mm 中的相关实现。

引用自—— 认真分析mmap:是什么 为什么 怎么用

mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

  1. forwardInvocationReplaceMethod 这个方法是实现 CJMethodLog 的核心部分:

     BOOL forwardInvocationReplaceMethod(Class cls, SEL originSelector, CJLogOptions options) {
         Method originMethod = class_getInstanceMethod(cls, originSelector);
         if (originMethod == nil) {
             return NO;
         }
         const char *originTypes = method_getTypeEncoding(originMethod);
         
         IMP msgForwardIMP = _objc_msgForward;
     #if !defined(__arm64__)
         if (isStructType(originTypes)) {
             //Reference JSPatch:
             //In some cases that returns struct, we should use the '_stret' API:
             //http://sealiesoftware.com/blog/archive/2008/10/30/objc_explain_objc_msgSend_stret.html
             //NSMethodSignature knows the detail but has no API to return, we can only get the info from debugDescription.
             NSMethodSignature *methodSignature = [NSMethodSignature signatureWithObjCTypes:originTypes];
             if ([methodSignature.debugDescription rangeOfString:@"is special struct return? YES"].location != NSNotFound) {
                 msgForwardIMP = (IMP)_objc_msgForward_stret;
             }
         }
     #endif
         
         IMP originIMP = method_getImplementation(originMethod);
         if (originIMP == nil || originIMP == msgForwardIMP) {
             return NO;
         }
         
         //添加一个新方法,该方法的IMP是原方法的IMP,并且在hook到的forwardInvocation里调用新方法
         SEL newSelecotr = createNewSelector(originSelector);
         BOOL addSucess = class_addMethod(cls, newSelecotr, originIMP, originTypes);
         if (!addSucess) {
             NSString *str = NSStringFromSelector(newSelecotr);
             CJLNSLog(@"CJMethodLog: Class addMethod fail : %@,%@",cls,str);
             return NO;
         }
         
         //替换当前方法的IMP为msgForwardIMP,从而在调用时候触发消息转发
         class_replaceMethod(cls, originSelector, msgForwardIMP, originTypes);
         
         Method forwardInvocationMethod = class_getInstanceMethod(cls, @selector(forwardInvocation:));
         _VIMP originMethod_IMP = (_VIMP)method_getImplementation(forwardInvocationMethod);
         method_setImplementation(forwardInvocationMethod, imp_implementationWithBlock(^(id target, NSInvocation *invocation){
             
             SEL originSelector = invocation.selector;
             BOOL isInstance = isInstanceType(target);
             Class targetClass = isInstance?[target class]:object_getClass(target);
             if (class_respondsToSelector(targetClass, originSelector)) {
                 
                 _CJDeep ++;
                 NSString *originSelectorStr = NSStringFromSelector(originSelector);
                 NSMutableString *methodlog = [[NSMutableString alloc]initWithCapacity:3];
                 for (NSInteger deepLevel = 0; deepLevel <= _CJDeep; deepLevel ++) {
                     [methodlog appendString:@"-"];
                 }
                 
                 [methodlog appendFormat:@" <%@> ",targetClass];
                 
                 CFTimeInterval startTimeInterval = 0;
                 BOOL beginAndEnd = NO;
                 if ((options & CJLogMethodTimer) || (options & CJLogMethodArgs)) {
                     [methodlog appendFormat:@" begin: "];
                     if (options & CJLogMethodTimer) {
                         startTimeInterval = CACurrentMediaTime();
                     }
                     beginAndEnd = YES;
                 }
                 
                 if (isInstance) {
                     [methodlog appendFormat:@" -%@",originSelectorStr];
                 }else{
                     [methodlog appendFormat:@" +%@",originSelectorStr];
                 }
                 
                 if (options & CJLogMethodArgs) {
                     NSDictionary *methodArguments = CJMethodArguments(invocation);
                     NSArray *argumentArray = methodArguments[_CJMethodArgsListKey];
                     NSMutableString *argStr = [[NSMutableString alloc]initWithCapacity:3];
    
                     for (NSInteger i = 0; i < argumentArray.count; i++) {
                         id arg = argumentArray[i];
                         if (i == 0) {
                             [argStr appendFormat:@" ; args=[ argIndex:%@ argValue:%@",@(i),[arg description]];
                         }else{
                             [argStr appendFormat:@", argIndex:%@ argValue:%@",@(i),[arg description]];
                         }
                     }
                     if (argumentArray.count > 0) {
                         [argStr appendString:@" ]"];
                     }
                     [methodlog appendString:argStr];
                 }
                 
                 if (_logEnable) {
                     CJLNSLog(@"%@",methodlog);
                 }
                 [_logger flushAllocationStack:[NSString stringWithFormat:@"%@\n",methodlog]];
                 
                 [invocation setSelector:createNewSelector(originSelector)];
                 [invocation setTarget:target];            
                 [invocation invoke];
                 
                 if (beginAndEnd) {
                     [methodlog setString:[methodlog stringByReplacingOccurrencesOfString:@"begin: " withString:@"finish:"]];
                     
                     if (options & CJLogMethodTimer) {
                         CFTimeInterval endTimeInterval = CACurrentMediaTime();
                         [methodlog appendFormat:@" ; time=%f",(endTimeInterval-startTimeInterval)];
                     }
                     
                     if (options & CJLogMethodReturnValue) {
                         id returnValue = getReturnValue(invocation);
                         [methodlog appendFormat:@" ; return= %@",[returnValue description]];
                     }
                     
                     if (_logEnable) {
                         CJLNSLog(@"%@",methodlog);
                     }
                     [_logger flushAllocationStack:[NSString stringWithFormat:@"%@\n",methodlog]];
                 }
    
                 _CJDeep --;
                 
             }
             //如果target本身已经实现了对无法执行的方法的消息转发(forwardInvocation:),则这里要还原其本来的实现
             else {
                 originMethod_IMP(target,@selector(forwardInvocation:),invocation);
             }
             if (_CJDeep == -1) {
                 if (_logEnable) {
                     CJLNSLog(@"\n");
                 }
                 [_logger flushAllocationStack:@"\n"];
             }
         }));
         return YES;
     }
    
  • 首先判断SEL对应的Method是否存在

  • 然后获取消息转发IMP (msgForwardIMP),注意 _objc_msgForward_objc_msgForward_stret 的判断

  • 判断当前方法的IMP(originIMP)不为nil,并且不等于msgForwardIMP

  • 添加一个指定前缀("cjlMethod_")开头的新方法,该方法的IMP是原方法的IMP

  • 替换当前方法的IMP为msgForwardIMP,从而在调用时候触发消息转发

  • 获取记录当前class的 @selector(forwardInvocation:) 对应的IMP,然后重写其IMP实现,这里直接使用了 imp_implementationWithBlock(id _Nonnull block) 生成IMP

  • 调用方法,触发消息转发,进入@selector(forwardInvocation:) 对应的IMP内。
    首先判断当前Class是否可执行原来方法,这里注意一下实例方法以及类方法的判断,如果是类方法,取的是object_getClass()

      SEL originSelector = invocation.selector;
      BOOL isInstance = isInstanceType(target);
      Class targetClass = isInstance?[target class]:object_getClass(target);
      if (class_respondsToSelector(targetClass, originSelector)) {
          //TODO:写入日志以及还原原方法的执行
      }
      //如果target本身已经实现了对无法执行的方法的消息转发(forwardInvocation:),则这里要还原其本来的实现
      else {
          originMethod_IMP(target,@selector(forwardInvocation:),invocation);
      }
    

    当判断到当前方法需要执行日志监听时,拼装日志信息(_CJDeep 记录了方法执行的层级关系),然后执行一次日志写入操作:

      [_logger flushAllocationStack:[NSString stringWithFormat:@"%@\n",methodlog]];
    

    再接着还原实际调用方法的实现:

      [invocation setSelector:createNewSelector(originSelector)];
      [invocation setTarget:target];            
      [invocation invoke];
    

    紧接着如果存在 CJLogMethodTimer 选项,则计算当前函数执行时间,同时写入日志信息。
    最后将 _CJDeep - 1,从而完成本次hook方法的执行。

  1. CJMethodLog 无法同时 hook super方法
    是否还记得上篇文章讲到,Objective-C中执行方法,其实底层调用的是objc_msgSend(receiver,SEL);如果是super方法,会调用objc_msgSendSuper(objc_super,SEL),再往下会转换成objc_msgSend(objc_super->receiver, SEL),此时的receiver和 objc_super->receiver 表示的是同一个接收者。
    比如下面例子:

     - (void)viewDidLoad {
         [super viewDidLoad];
     }
    
     底层对应的是
     objc_msgSend(self,@selector(viewDidLoad))
     objc_msgSend(objc_super->receiver, @selector(viewDidLoad))
    

    当CJMethodLog都hook了父子类的 viewDidLoad 方法后,调用会触发消息转发,最终由以下代码还原其实现

     [invocation setSelector:createNewSelector(@selector(viewDidLoad))];
     [invocation setTarget:target];            
     [invocation invoke];
    

    子类方法 target=self,父类方法 target=objc_super->receiver,其中self = objc_super->receiver,到此为止你是否发现了问题所在?问题就是这里的方法调用其实是一个死循环!!!伪代码如下:

     objc_msgSend(receiver,@selector(viewDidLoad)) {
         objc_msgSend(receiver, @selector(viewDidLoad));
     }
    

    要破解这一难题只能想办法区分父子类调用方法时候的不同上下文,可惜这一块我还没找到好的解决方案。


更多

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

推荐阅读更多精彩内容