写点什么

iOS AOP 方案的对比与思考

发布于: 2020 年 11 月 23 日
iOS AOP 方案的对比与思考

AOP 思想

AOP:Aspect Oriented Programming,译为面向切面编程,是可以通过预编译的方式和运行期动态实现,在不修改源代码的情况下,给程序动态统一添加功能的技术。

面向对象编程(OOP)适合定义从上到下的关系,但不适用于从左到右,计算机中任何一门新技术或者新概念的出现都是为了解决一个特定的问题的,我们看下 AOP 解决了什么样的问题。

例如一个电商系统,有很多业务模块的功能,使用 OOP 来实现核心业务是合理的,我们需要实现一个日志系统,和模块功能不同,日志系统不属于业务代码。如果新建一个工具类,封装日志打印方法,再在原有类中进行调用,就增加了耦合性,我们需要从业务代码中抽离日志系统,然后独立到非业务的功能代码中,这样我们改变这些行为时,就不会影响现有业务代码。

当我们使用各种技术来拦截方法,在方法执行前后做你想做的事,例如日志打印,就是所谓的 AOP。

主流的 AOP 方案

Method Swizzle

说到 iOS 中 AOP 的方案第一个想到的应该就是 Method Swizzle

得益于 Objective-C 这门语言的动态性,我们可以让程序在运行时做出一些改变,进而调用我们自己定义的方法。使用 Runtime 交换方法的核心就是:method_exchangeImplementations, 它实际上将两个方法的实现进行交换:

+ (void)load{    static dispatch_once_t onceToken;    dispatch_once(&onceToken, ^{        Class aClass = [self class];                SEL originalSelector = @selector(method_original:);        SEL swizzledSelector = @selector(method_swizzle:);                Method originalMethod = class_getInstanceMethod(aClass, originalSelector);        Method swizzledMethod = class_getInstanceMethod(aClass, swizzledSelector);        BOOL didAddMethod = class_addMethod(aClass,										                        originalSelector,                    									      method_getImplementation(swizzledMethod),										                        method_getTypeEncoding(swizzledMethod));                if (didAddMethod) {            class_replaceMethod(aClass,                                swizzledSelector,                                method_getImplementation(originalMethod),                                method_getTypeEncoding(originalMethod));        } else {            method_exchangeImplementations(originalMethod, swizzledMethod);        }    });}
复制代码


作为我们常说的黑魔法 Method Swizzle 到底危险不危险,有没有最佳实践。

这里可以通过这篇回答一起深入理解下。这里列出了一些 Method Swizzling 的陷阱:

  • Method swizzling is not atomic

你会把 Method Swizzling 修改方法实现的操作放在一个加号方法 +(void)load 里,并在应用程序的一开始就调用执行,通常放在 dispatch_once() 里面来调用。你绝大多数情况将不会碰到并发问题。

  • Changes behavior of un-owned code

这是 Method Swizzling 的一个问题。我们的目标是改变某些代码。当你不只是对一个 UIButton 类的实例进行了修改,而是程序中所有的 UIButton 实例,对原来的类侵入较大。

  • Possible naming conflicts

命名冲突贯穿整个 Cocoa 的问题. 我们常常在类名和类别方法名前加上前缀。不幸的是,命名冲突仍是个折磨。但是 swizzling 其实也不必过多考虑这个问题。我们只需要在原始方法命名前做小小的改动来命名就好,比如通常我们这样命名:

@interface UIView : NSObject- (void)setFrame:(NSRect)frame;@end @implementation UIView (MyViewAdditions) - (void)my_setFrame:(NSRect)frame {    // do custom work    [self my_setFrame:frame];} 
+ (void)load { [self swizzle:@selector(setFrame:) with:@selector(my_setFrame:)];} @end
复制代码


这段代码运行是没问题的,但是如果 my_setFrame: 在别处被定义了会发生什么呢?比如在别的分类中,当然这个问题不仅仅存在于 swizzling 中,其他地方也可能会出现,这里可以有个变通的方法,利用函数指针来定义

@implementation UIView (MyViewAdditions) static void MySetFrame(id self, SEL _cmd, NSRect frame);static void (*SetFrameIMP)(id self, SEL _cmd, NSRect frame); static void MySetFrame(id self, SEL _cmd, NSRect frame) {    // do custom work    SetFrameIMP(self, _cmd, frame);} + (void)load {    [self swizzle:@selector(setFrame:) with:(IMP)MySetFrame store:(IMP *)&SetFrameIMP];} @end
复制代码


  • Swizzling changes the method's arguments

我认为这是最大的问题。想正常调用 Method Swizzling 的方法将会是个问题。比如我想调用 my_setFrame:

[self my_setFrame:frame];
复制代码


Runtime 做的是 objc_msgSend(self, @selector(my_setFrame:), frame); Runtime 去寻找 my_setFrame:的方法实现,但因为已经被交换了,事实上找到的方法实现是原始的 setFrame: 的,如果想调用 Method Swizzling 的方法,可以通过上面的函数的方式来定义,不走 Runtime 的消息发送流程。不过这种需求场景很少见。

  • The order of swizzles matters

多个 swizzle 方法的执行顺序也需要注意。假设 setFrame: 只定义在 UIivew 中,想像一下按照下面的顺序执行:

[UIView swizzle:@selector(setFrame:) with:@selector(my_viewSetFrame:)];[UIControl swizzle:@selector(setFrame:) with:@selector(my_controlSetFrame:)];[UIButton swizzle:@selector(setFrame:) with:@selector(my_buttonSetFrame:)];
复制代码


这里需要注意的是 swizzle 的顺序,多个有继承关系的类的对象 swizzle 时,先从父对象开始。 这样才能保证子类方法拿到父类中的被 swizzle 的实现。在+(void)load 中 swizzle 不会出错,就是因为 load 类方法会默认从父类开始调用,不过这种场景很少,一般会选择一个类进行 swizzle。

  • Difficult to understand (looks recursive)

新方法的实现里面会调用自己同名的方法,看起来像递归,但是看看上面已经给出的 swizzling 封装方法, 使用起来就很易读懂,这个问题是已完全解决的了!

  • Difficult to debug

调试时不管通过 bt 命令还是 [NSThread callStackSymbols] 打印调用栈,其中掺杂着被 swizzle 的方法名,会显得一团槽!上面介绍的 swizzle 方案,使 backtrace 中打印出的方法名还是很清晰的。但仍然很难去 debug,因为很难记住 swizzling 影响过什么。给你的代码写好文档(即使只有你一个人会看到),统一管理一些 swizzling 的方法,而不是分散到业务的各个模块。相对于调试多线程问题 Method Swizzling 要简单很多。

Aspects

Aspects 是 iOS 上的一个轻量级 AOP 库。它利用 Method Swizzling 技术为已有的类或者实例方法添加额外的代码,使用起来是很方便:

/// Adds a block of code before/instead/after the current `selector` for a specific class.+ (id<AspectToken>)aspect_hookSelector:(SEL)selector                           withOptions:(AspectOptions)options                            usingBlock:(id)block                            		 error:(NSError **)error;
/// Adds a block of code before/instead/after the current `selector` for a specific instance.- (id<AspectToken>)aspect_hookSelector:(SEL)selector withOptions:(AspectOptions)options usingBlock:(id)block error:(NSError **)error;
复制代码


Aspects 提供了 2 个 AOP 方法,一个用于类,一个用于实例。在确定 hook 的 方法之后, Aspects 允许我们选择 hook 的时机是在方法执行之前,还是方法执行之后,甚至可以直接替换掉方法的实现。网上有很多介绍其实现原理的文章,在 iOS 开源社区中算是少有的精品代码,对深入理解掌握 ObjC 的消息发送机制很有帮助。但其存在的缺陷就是性能较差,如官方所说

Aspects uses Objective-C message forwarding to hook into messages. This will create some overhead. Don't add aspects to methods that are called a lot. Aspects is meant for view/controller code that is not called a 1000 times per second.

Aspects hooks deep into the class hierarchy and creates dynamic subclasses, much like KVO. There's known issues with this approach, and to this date (February 2019) I STRICTLY DO NOT RECOMMEND TO USE Aspects IN PRODUCTION CODE. We use it for partial test mocks in, PSPDFKit, an iOS PDF framework that ships with apps like Dropbox or Evernote, it's also very useful for quickly hacking something up.

官方强烈不推荐在生产环境中使用,一般用来在单测中做一些 mock 操作。我们这边的性能测试也证明了这一点:在 iPhone 6 真机上,循环 100w 次的方法调用(已经通过 Aspects hook 的方法)中会直接报 Terminated due to memory issue crash 错误信息。

MPSwizzler

MPSwizzler 这个是开源数据分析 SDK MixPanel 中采用的一种 AOP 方案,原理不是很复杂,主要还是基于 ObjC 的运行时。

  1. 支持运行时取消对应的 hook,这里可以满足一些需求场景的

  2. 通过 block 的方式来执行方法块,避免方法命名的冲突

+ (void)swizzleSelector:(SEL)aSelector onClass:(Class)aClass withBlock:(swizzleBlock)aBlock named:(NSString *)aName{    Method aMethod = class_getInstanceMethod(aClass, aSelector);    if (aMethod) {        uint numArgs = method_getNumberOfArguments(aMethod);        if (numArgs >= MIN_ARGS && numArgs <= MAX_ARGS) {                            // 判断该方法是否在自己类的方法列表中,而不是父类            BOOL isLocal = [self isLocallyDefinedMethod:aMethod onClass:aClass];            IMP swizzledMethod = (IMP)mp_swizzledMethods[numArgs - 2];            MPSwizzle *swizzle = [self swizzleForMethod:aMethod];                            if (isLocal) {                if (!swizzle) {                    IMP originalMethod = method_getImplementation(aMethod);                                            // Replace the local implementation of this method with the swizzled one                    method_setImplementation(aMethod,swizzledMethod);                                            // Create and add the swizzle                    swizzle = [[MPSwizzle alloc] initWithBlock:aBlock named:aName forClass:aClass selector:aSelector originalMethod:originalMethod withNumArgs:numArgs];                    [self setSwizzle:swizzle forMethod:aMethod];                                        } else {                    [swizzle.blocks setObject:aBlock forKey:aName];                }            } else {            // 如果是父类的方法会添加到自身,避免对父类侵入                IMP originalMethod = swizzle ? swizzle.originalMethod : method_getImplementation(aMethod);                                    // Add the swizzle as a new local method on the class.                if (!class_addMethod(aClass, aSelector, swizzledMethod, method_getTypeEncoding(aMethod))) {                    NSAssert(NO, @"SwizzlerAssert: Could not add swizzled for %@::%@, even though it didn't already exist locally", NSStringFromClass(aClass), NSStringFromSelector(aSelector));                    return;                }                // Now re-get the Method, it should be the one we just added.                Method newMethod = class_getInstanceMethod(aClass, aSelector);                if (aMethod == newMethod) {                    NSAssert(NO, @"SwizzlerAssert: Newly added method for %@::%@ was the same as the old method", NSStringFromClass(aClass), NSStringFromSelector(aSelector));                    return;                }                                    MPSwizzle *newSwizzle = [[MPSwizzle alloc] initWithBlock:aBlock named:aName forClass:aClass selector:aSelector originalMethod:originalMethod withNumArgs:numArgs];                [self setSwizzle:newSwizzle forMethod:newMethod];            }        } else {            NSAssert(NO, @"SwizzlerAssert: Cannot swizzle method with %d args", numArgs);        }    } else {        NSAssert(NO, @"SwizzlerAssert: Cannot find method for %@ on %@", NSStringFromSelector(aSelector), NSStringFromClass(aClass));    }}
复制代码


其中最主要的就是 method_setImplementation(aMethod,swizzledMethod); 其中 swizzledMethod 是根据原来方法的参数匹配到对应的如下几个函数:

  1. static void mp_swizzledMethod_2(id self, SEL _cmd)

  2. static void mp_swizzledMethod_3(id self, SEL _cmd, id arg)

  3. static void mp_swizzledMethod_4(id self, SEL _cmd, id arg, id arg2)

  4. static void mp_swizzledMethod_5(id self, SEL _cmd, id arg, id arg2, id arg3)

这个几个函数内部实现大体一样的,以 mp_swizzledMethod_4 为例:

static void mp_swizzledMethod_4(id self, SEL _cmd, id arg, id arg2){    Method aMethod = class_getInstanceMethod([self class], _cmd);    // 1. 获取保存hook 的实体类    MPSwizzle *swizzle = (MPSwizzle *)[swizzles objectForKey:(__bridge id)((void *)aMethod)];    if (swizzle) {    // 2. 先调用原来的方法        ((void(*)(id, SEL, id, id))swizzle.originalMethod)(self, _cmd, arg, arg2);
NSEnumerator *blocks = [swizzle.blocks objectEnumerator]; swizzleBlock block; // 3. 再循环调用 hook 的方法块,可能绑定了多个 while ((block = [blocks nextObject])) { block(self, _cmd, arg, arg2); } }}
复制代码


这个 AOP 的方案在多数 SDK 中也均采用了,比如 FBSDKSwizzlerSASwizzler,相比于 Aspects 性能好太多、但与 朴素的 Method Swizzling 相比还有差距。

ISA-swizzle KVO

利用 KVO 的运行时 ISA-swizzle 原理,动态创建子类、并重写相关方法,并且添加我们想要的方法,然后在这个方法中调用原来的方法,从而达到 hook 的目的。这里以 ReactiveCocoa 的作为示例。


internal func swizzle(_ pairs: (Selector, Any)..., key hasSwizzledKey: AssociationKey<Bool>) {
// 动态创建子类 let subclass: AnyClass = swizzleClass(self)
ReactiveCocoa.synchronized(subclass) { let subclassAssociations = Associations(subclass as AnyObject)
if !subclassAssociations.value(forKey: hasSwizzledKey) { subclassAssociations.setValue(true, forKey: hasSwizzledKey)
for (selector, body) in pairs { let method = class_getInstanceMethod(subclass, selector)! let typeEncoding = method_getTypeEncoding(method)!
if method_getImplementation(method) == _rac_objc_msgForward { let succeeds = class_addMethod(subclass, selector.interopAlias, imp_implementationWithBlock(body), typeEncoding) precondition(succeeds, "RAC attempts to swizzle a selector that has message forwarding enabled with a runtime injected implementation. This is unsupported in the current version.") } else { // 通过 block 生成一个新的 IMP,为生成的子类添加该方法实现。 let succeeds = class_addMethod(subclass, selector, imp_implementationWithBlock(body), typeEncoding) precondition(succeeds, "RAC attempts to swizzle a selector that has already a runtime injected implementation. This is unsupported in the current version.") } } } } }
internal func swizzleClass(_ instance: NSObject) -> AnyClass { if let knownSubclass = instance.associations.value(forKey: knownRuntimeSubclassKey) { return knownSubclass }
let perceivedClass: AnyClass = instance.objcClass let realClass: AnyClass = object_getClass(instance)! let realClassAssociations = Associations(realClass as AnyObject)
if perceivedClass != realClass { // If the class is already lying about what it is, it's probably a KVO // dynamic subclass or something else that we shouldn't subclass at runtime. synchronized(realClass) { let isSwizzled = realClassAssociations.value(forKey: runtimeSubclassedKey) if !isSwizzled { // 重写类的 -class 和 +class 方法,隐藏真实的子类类型 replaceGetClass(in: realClass, decoy: perceivedClass) realClassAssociations.setValue(true, forKey: runtimeSubclassedKey) } }
return realClass } else { let name = subclassName(of: perceivedClass) let subclass: AnyClass = name.withCString { cString in if let existingClass = objc_getClass(cString) as! AnyClass? { return existingClass } else { let subclass: AnyClass = objc_allocateClassPair(perceivedClass, cString, 0)! // 重写类的 -class 和 +class 方法,隐藏真实的子类类型 replaceGetClass(in: subclass, decoy: perceivedClass) objc_registerClassPair(subclass) return subclass } }
object_setClass(instance, subclass) instance.associations.setValue(subclass, forKey: knownRuntimeSubclassKey) return subclass }}
复制代码


其中 RxSwift 中的 _RXObjCRuntime 也提供了类似的思路。

当然也可以不用自己通过 objc_registerClassPair() 创建类,直接通过 KVO 由系统帮我们生成子类,例如:


static void growing_viewDidAppear(UIViewController *kvo_self, SEL _sel, BOOL animated) { Class kvo_cls = object_getClass(kvo_self); Class origin_cls = class_getSuperclass(kvo_cls); IMP origin_imp = method_getImplementation(class_getInstanceMethod(origin_cls, _sel)); assert(origin_imp != NULL); void (*origin_method)(UIViewController *, SEL, BOOL) = (void (*)(UIViewController *, SEL, BOOL))origin_imp; // 调用原来的方法 origin_method(kvo_self, _sel, animated); // Do something }
- (void)createKVOClass { [self addObserver:[GrowingKVOObserver shared] forKeyPath:kooUniqueKeyPath options:NSKeyValueObservingOptionNew context:nil];
GrowingKVORemover *remover = [[GrowingKVORemover alloc] init]; remover.target = self; remover.keyPath = growingUniqueKeyPath; objc_setAssociatedObject(self, &growingAssociatedRemoverKey, remover, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
// 通过object_getClass 取到的class 是由系统生成的前缀为 NSKVONotifying_ 的类型 Class kvoCls = object_getClass(self);
Class originCls = class_getSuperclass(kvoCls);
const char *originViewDidAppearEncoding = method_getTypeEncoding(class_getInstanceMethod(originCls, @selector(viewDidAppear:)));
// 添加我们自己的实现 growing_viewDidAppear class_addMethod(kvoCls, @selector(viewDidAppear:), (IMP)growing_viewDidAppear, originViewDidAppearEncoding);}
复制代码


这种利用 KVO 动态生成子类的 AOP 方案对原来的类侵入最小,因为它没有改变原始类的方法和实现的映射关系,也就不会影响到由原始类定义的其他的实例的方法调用。在一些比如更精确的计算页面加载时间的场景中会发挥很好的作用。但是这个 AOP 的方案和其他一些 SDK 有冲突的情形,比如信鸽、Firebase 以及上面说的 RxSwift,在 RxSwift 中所有的消息机制都被统一成了信号,框架不推荐你使用 Delegate、KVO、Notification,尤其 KVO 会有异常错误的。

Fishhook

提高 iOS 的 AOP 方案就不得不提到大名鼎鼎的 Fishook,它在做一些性能分析或者越狱分析中经常被用到。

大家都知道 ObjC 的方法之所以可以 Hook 是因为它的运行时特性,ObjC 的方法调用在底层都是 objc_msgSend(id, SEL) 的形式,这为我们提供了交换方法实现(IMP)的机会,但 C 函数在编译链接时就确定了函数指针的地址偏移量(Offset),这个偏移量在编译好的可执行文件中是固定的,而可执行文件每次被重新装载到内存中时被系统分配的起始地址(在 lldb 中用命令 image List 获取)是不断变化的。运行中的静态函数指针地址其实就等于上述 Offset + Mach0 文件在内存中的首地址。

既然 C 函数的指针地址是相对固定且不可修改的,那么 fishhook 又是怎么实现 对 C 函数的 Hook 呢?其实内部/自定义的 C 函数 fishhook 也 Hook 不了,它只能 Hook Mach-O 外部(共享缓存库中)的函数,比如 NSLog、objc_msgSend 等动态符号表中的符号。

fishhook 利用了 MachO 的动态绑定机制,苹果的共享缓存库不会被编译进我们的 MachO 文件,而是在动态链接(依靠动态连接器 dyld)时才去重新绑定。苹果采用了 PIC(Position-independent code)技术成功让 C 的底层也能有动态的表现:

  • 编译时在 Mach-O 文件 _DATA 段的符号表中为每一个被引用的系统 C 函数建立一个指针(8 字节的数据,放的全是 0),这个指针用于动态绑定时重定位到共享库中的函数实现。

  • 在运行时当系统 C 函数被第一次调用时会动态绑定一次,然后将 Mach-O 中的 _DATA 段符号表中对应的指针,指向外部函数(其在共享库中的实际内存地址)。

fishhook 正是利用了 PIC 技术做了这么两个操作:

  • 将指向系统方法(外部函数)的指针重新进行绑定指向内部函数/自定义 C 函数。

  • 将内部函数的指针在动态链接时指向系统方法的地址。

这是 Facebook 提供的官方示意图:


Lazy Symbol Pointer Table --> Indirect Symbol Table --> Symbol Table --> String Table

这张图主要在描述如何由一个字符串(比如 "NSLog"),根据它在 MachO 文件的懒加载表中对应的指针,一步步的找到该指针指向的函数实现地址,我们通过 MachOView 工具来分析下这个步骤:

_la_sysmbol_ptr 该 section 表示 懒加载的符号指针,其中的 value,是对保留字段的解析,表示在 Indirect Symbol Table 中的索引


 通过 reserve1 找到 对应 section __la_symbol_ptr 在动态符号表(Indirect Symbols)中的位置,比如下图:#14 就是 __la_symbol_ptr section 所在的起始位置。


符号个数计算 是通过 sizeof(void (* )) 指针在 64 位上时 8 个字节大小,所要这个__la_symbol_ptr section 有 104 / 8 = 13 个符号,_NSLog 只是其中之一。


 注意 Indirect Symbols 动态符号表,其中的 Data 值 0x00CO (#192) 表示该符号在符号表中的索引


 符号表中的第 192 号就是 _NSLog 符号,这个 Data 0x00CE 就是字符串表中的索引


 上面的索引 0x00CE 加上这个字符串表的起始值 0xD2B4 就是该符号在符号表中的位置,如下图所示:


 以上梳理了 fishhook 大概的流程,之后看代码的实现就不是很抽象了,需要对 MachO 文件的结构有较深入的理解。既然 fishhook 可以 hook 系统静态的 C 函数,那么也可以 hook ObjC 中的 Runtime 相关的方法,比如 objc_msgSend、method_getImplementation、method_setImplementation、method_exchangeImplementations 可以做一些有趣的攻防探索、其中越狱中常用的 Cydia Substrate 其中的 MobileHooker 底层就是调用 fishhook 和 ObjC 的 Runtime 来替换系统或者目标应用的函数。对其封装较好的 theos 或者 MonkeyDev 开发工具方便越狱进行 hook 分析。需要注意的是 fishhook 对于变参函数的处理比较麻烦,不太方便拿到所有的可变的参数,需要借助汇编来操作栈和寄存器。关于这部分可以参见:TimeProfilerAppleTrace

Thunk 技术

让我们把镜头进一步向前推进,了解下 Thunk 技术。

Thunk 程序中文翻译为形实转换程序,简而言之 Thunk 程序就是一段代码块,这段代码块可以在调用真正的函数前后进行一些附加的计算和逻辑处理,或者提供将对原函数的直接调用转化为间接调用的能力。Thunk 程序在有的地方又被称为跳板(trampoline)程序,Thunk 程序不会破坏原始被调用函数的栈参数结构,只是提供了一个原始调用的 hook 的能力。Thunk 技术可以在编译时和运行时两种场景下被使用。其主要的思想就是在运行时我们自己在内存中构造一段指令让 CPU 执行。关于 Thunk 思想在 iOS 中的实现可以参见 Thunk程序的实现原理以及在iOS中的应用Thunk程序的实现原理以及在iOS中的应用 从背景理论到实践来分析这一思想。

关于 Thunk 思想的具体实现可以参见下面几个三方库以相关的博客:

其中核心都会利用到 libffi 这个库,底层是汇编写的,libfii 可以理解为实现了 C 语言上的 Runtime。

Clang 插桩

以上 iOS AOP 方案中大多是基于运行时的,fishhook 是基于链接阶段的,而编译阶段能否实现 AOP 呢,插入我们想要的代码呢?

作为 Xcode 内置的编译器 Clang 其实是提供了一套插桩机制,用于代码覆盖检测,官方文档如下:Clang自带的代码覆盖工具,关于 Clang 插桩的一个应用可以详见这篇文章,最终是由编译器在指定的位置帮我们加上了特定的指令,生成最终的可执行文件,编写更多的自定义的插桩规则需要自己手写 llvm pass

这种依赖编译器做的 AOP 方案,适用于与开发、测试阶段做一些检测工具,例如:代码覆盖、Code Lint、静态分析等。

总结

以上介绍了 iOS 中主流的 AOP 的方案和一些知名的框架,有编译期、链接期、运行时的,从源代码到程序装载到内存执行,整个过程的不同阶段都可以有相应的方案进行选择。我们的工具箱又多出了一些可供选择,同时进一步加深对静态和动态语言的理解,也对程序从静态到动态整个过程理解更加深入。

同时我们 Android 和 iOS 无埋点 SDK 3.0 均已开源,有兴趣可以关注下面 github 仓库,了解我们最新的开发进展。

Android

iOS

发布于: 2020 年 11 月 23 日阅读数: 22
用户头像

GrowingIO 技术团队经验分享 2020.05.09 加入

GrowingIO(官网网站www.growingio.com)的官方技术专栏,内容涵盖微服务架构,前端技术,数据可视化,DevOps,大数据方面的经验分享。 公众号:GrowingIO技术团队

评论

发布
暂无评论
iOS AOP 方案的对比与思考