共计 14816 个字符,预计需要花费 38 分钟才能阅读完成。
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 的运行时。
- 反对运行时勾销对应的 hook,这里能够满足一些需要场景的
- 通过 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 是依据原来办法的参数匹配到对应的如下几个函数:
static void mp_swizzledMethod_2(id self, SEL _cmd)
static void mp_swizzledMethod_3(id self, SEL _cmd, id arg)
static void mp_swizzledMethod_4(id self, SEL _cmd, id arg, id arg2)
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 中也均采纳了,比方 FBSDKSwizzler、SASwizzler,相比于 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 对于变参函数的解决比拟麻烦,不太不便拿到所有的可变的参数,须要借助汇编来操作栈和寄存器。对于这部分能够参见:TimeProfiler、AppleTrace。
Thunk 技术
让咱们把镜头进一步向前推动,理解下 Thunk 技术。
Thunk 程序中文翻译为形实转换程序,简而言之 Thunk 程序就是一段代码块,这段代码块能够在调用真正的函数前后进行一些附加的计算和逻辑解决,或者提供将对原函数的间接调用转化为间接调用的能力。Thunk 程序在有的中央又被称为跳板 (trampoline) 程序,Thunk 程序不会毁坏原始被调用函数的栈参数构造,只是提供了一个原始调用的 hook 的能力。Thunk 技术能够在编译时和运行时两种场景下被应用。其次要的思维就是在运行时咱们本人在内存中结构一段指令让 CPU 执行。对于 Thunk 思维在 iOS 中的实现能够参见 Thunk 程序的实现原理以及在 iOS 中的利用 和 Thunk 程序的实现原理以及在 iOS 中的利用 从背景实践到实际来剖析这一思维。
对于 Thunk 思维的具体实现能够参见上面几个三方库以相干的博客:
- Stinger
- TrampolineHook
其中外围都会利用到 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:https://github.com/growingio/growingio-sdk-android-autotracker
iOS:https://github.com/growingio/growingio-sdk-ios-autotracker
对于 GrowingIO
GrowingIO 是国内当先的一站式数字化增长整体计划服务商。为产品、经营、市场、数据团队及管理者提供客户数据平台(CDP)、广告剖析、产品剖析、智能经营等产品和咨询服务,帮忙企业在数字化转型的路上,晋升数据驱动能力,实现更好的增长。
点击「此处」,注册 GrowingIO 15 天收费试用!