AOP 思想
AOP:Aspect Oriented Programming,译为面向切面编程,是可以通过预编译的方式和运行期动态实现,在不修改源代码的情况下,给程序动态统一添加功能的技术。
面向对象编程(OOP)适合定义从上到下的关系,但不适用于从左到右,计算机中任何一门新技术或者新概念的出现都是为了解决一个特定的问题的,我们看下AOP解决了什么样的问题。
例如一个电商系统,有很多业务模块的功能,使用OOP来实现核心业务是合理的,我们需要实现一个日志系统,和模块功能不同,日志系统不属于业务代码。如果新建一个工具类,封装日志打印方法,再在原有类中进行调用,就增加了耦合性,我们需要从业务代码中抽离日志系统,然后独立到非业务的功能代码中,这样我们改变这些行为时,就不会影响现有业务代码。
当我们使用各种技术来拦截方法,在方法执行前后做你想做的事,例如日志打印,就是所谓的AOP。
主流的AOP 方案
Method Swizzle
说到iOS中AOP的方案第一个想到的应该就是 Method Swizzle
作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:413038000,不管你是小白还是大牛欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!
得益于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 所在的起始位置。
[图片上传中...(image-6b9ec5-1607149385528-4)]
符号个数计算 是通过 sizeof(void (* )) 指针在64位上时8个字节大小,所要这个__la_symbol_ptr section 有 104 / 8 = 13 个符号,_NSLog 只是其中之一。
[图片上传中...(image-81fb87-1607149385528-3)]
注意 Indirect Symbols 动态符号表,其中的Data 值 0x00CO (#192) 表示该符号在符号表中的索引
符号表中的第192号就是 _NSLog 符号,这个Data 0x00CE 就是字符串表中的索引
上面的索引 0x00CE 加上这个字符串表的起始值 0xD2B4 就是该符号在符号表中的位置,如下图所示:
[图片上传中...(image-bedb85-1607149385528-0)]
以上梳理了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 思想的具体实现可以参见下面几个三方库以相关的博客:
其中核心都会利用到 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:github.com/growingio/g…
关于 GrowingIO
GrowingIO 是国内领先的一站式数字化增长整体方案服务商。为产品、运营、市场、数据团队及管理者提供客户数据平台(CDP)、广告分析、产品分析、智能运营等产品和咨询服务,帮助企业在数字化转型的路上,提升数据驱动能力,实现更好的增长。
作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:413038000,不管你是小白还是大牛欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!
作者:GrowingIO技术社区
链接:https://juejin.cn/post/6898192050512986126
来源:掘金