Apptimize \ Optimizely 如何在 iOS 上工作?
How does Apptimize \ Optimizely work on iOS?
我正在尝试弄清楚一些关于 "behind the scene" 实施的一些事情,以便直接从 Apptimize 或 Optimizely 上的 Web 控制台即时操作 UI 元素。
更具体地说,我想了解以下内容:
1) 客户端代码 (iOS) 如何将视图层次结构发送到 Web 服务器,以便当您在 Web 仪表板上选择任何 UI 元素时,它会立即显示在 iOS 客户端上?
我看到了 FLEX,以及它如何设法获取视图层次结构,但我不明白 iphone 客户端 "knows" 如何在 Web 仪表板中选择哪个视图。
2) 此外,在 Apptimize 中,我可以从 Web 仪表板中选择任何 UI 元素,更改其文本或颜色,它会立即在应用程序中发生变化。不仅如此,无需添加任何代码,只需拥有SDK。
我所做的更改(文本、背景颜色等)将在应用程序的所有未来会话 中保留。如何实施?
我猜他们正在使用某种反射,但他们如何才能让它对所有用户以及所有未来的会话起作用?客户端代码如何找到正确的 UI 元素?它如何在 UITableViewCell 上工作?
3) 是否可以在每次加载UIViewController时进行检测?即在每个 viewDidLoad 上获得回调?如果可以,怎么做?
查看下面的一些屏幕截图:
我也有同样的疑问,但找不到明确的答案,所以这是我(希望)有根据的猜测:
感谢运行时环境,在Cocoa(-Touch)中使用Aspect-Orientated-Programming (AOP)实际上并不难,其中编写规则以挂钩其他类方法电话。
如果您 google 用于 AOP
和 Objective-C
,则会弹出几个很好地包装运行时代码的库。
例如 steinpete 的 Aspect 图书馆:
[UIViewController aspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo, BOOL animated) {
NSLog(@"View Controller %@ will appear animated: %tu", aspectInfo.instance, animated);
} error:NULL];
这个方法调用
+ (id<AspectToken>)aspect_hookSelector:(SEL)selector
withOptions:(AspectOptions)options
usingBlock:(id)block
error:(NSError **)error {
return aspect_add((id)self, selector, options, block, error);
}
调用 aspect_add()
static id aspect_add(id self, SEL selector, AspectOptions options, id block, NSError **error) {
NSCParameterAssert(self);
NSCParameterAssert(selector);
NSCParameterAssert(block);
__block AspectIdentifier *identifier = nil;
aspect_performLocked(^{
if (aspect_isSelectorAllowedAndTrack(self, selector, options, error)) {
AspectsContainer *aspectContainer = aspect_getContainerForObject(self, selector);
identifier = [AspectIdentifier identifierWithSelector:selector object:self options:options block:block error:error];
if (identifier) {
[aspectContainer addAspect:identifier withOptions:options];
// Modify the class to allow message interception.
aspect_prepareClassAndHookSelector(self, selector, error);
}
}
});
return identifier;
}
它再次调用其他几个看起来非常可怕的函数,它们在运行时执行繁重的工作
static void aspect_prepareClassAndHookSelector(NSObject *self, SEL selector, NSError **error) {
NSCParameterAssert(selector);
Class klass = aspect_hookClass(self, error);
Method targetMethod = class_getInstanceMethod(klass, selector);
IMP targetMethodIMP = method_getImplementation(targetMethod);
if (!aspect_isMsgForwardIMP(targetMethodIMP)) {
// Make a method alias for the existing method implementation, it not already copied.
const char *typeEncoding = method_getTypeEncoding(targetMethod);
SEL aliasSelector = aspect_aliasForSelector(selector);
if (![klass instancesRespondToSelector:aliasSelector]) {
__unused BOOL addedAlias = class_addMethod(klass, aliasSelector, method_getImplementation(targetMethod), typeEncoding);
NSCAssert(addedAlias, @"Original implementation for %@ is already copied to %@ on %@", NSStringFromSelector(selector), NSStringFromSelector(aliasSelector), klass);
}
// We use forwardInvocation to hook in.
class_replaceMethod(klass, selector, aspect_getMsgForwardIMP(self, selector), typeEncoding);
AspectLog(@"Aspects: Installed hook for -[%@ %@].", klass, NSStringFromSelector(selector));
}
}
很容易看出,这里我们有一个工具,它允许我们发送应用程序的当前状态以在网页中重新构建它,还可以在现有代码中操作对象。
当然这只是一个起点。您将需要一个 Web 服务来组装应用程序并将其发送给用户。
就我个人而言,我从未将 AOP 用于如此复杂的任务,但我将其用于教授所有视图控制器的跟踪功能
- (void)setupViewControllerTracking
{
NSError *error;
@weakify(self);
[UIViewController aspect_hookSelector:@selector(viewDidAppear:)
withOptions:AspectPositionAfter
usingBlock:^(id < AspectInfo > aspectInfo) {
@strongify(self);
UIViewController *viewController = [aspectInfo instance];
NSArray *breadCrumbs = [self breadCrumbsForViewController:viewController];
if (breadCrumbs.count) {
NSString *pageName = [NSString stringWithFormat:@"/%@", [breadCrumbs componentsJoinedByString:@"/"]];
[ARAnalytics pageView:pageName];
}
} error:&error];
}
更新
我玩了一下,能够创建一个原型。如果添加到项目中,它将通过使用 AOP 和动态方法添加将所有视图控制器背景颜色更改为蓝色,并在 5 秒后将所有实时视图控制器背景颜色更改为橙色。
源代码:https://gist.github.com/vikingosegundo/0e4b30901b9498ae4b7b
这 5 秒是由通知触发的,但很明显这可能是网络事件。
更新 2
我教我的原型打开网络接口并接受背景的 rgb 值。
运行 在模拟器中这将是
http://127.0.0.1:8080/color/<r>/<g>/<b>/
http://127.0.0.1:8080/color/50/120/220/
我用 OCFWebServer
//
// ABController.m
// ABTestPrototype
//
// Created by Manuel Meyer on 12.05.15.
// Copyright (c) 2015 Manuel Meyer. All rights reserved.
//
#import "ABController.h"
#import <Aspects/Aspects.h>
#import <OCFWebServer/OCFWebServer.h>
#import <OCFWebServer/OCFWebServerRequest.h>
#import <OCFWebServer/OCFWebServerResponse.h>
#import <objc/runtime.h>
#import "UIViewController+Updating.h"
#import "UIView+ABTesting.h"
@import UIKit;
@interface ABController ()
@property (nonatomic, strong) OCFWebServer *webserver;
@end
@implementation ABController
void _ab_register_ab_notificaction(id self, SEL _cmd)
{
[[NSNotificationCenter defaultCenter] addObserver:self selector:NSSelectorFromString(@"ab_notifaction:") name:@"ABTestUpdate" object:nil];
}
void _ab_notificaction(id self, SEL _cmd, id userObj)
{
NSLog(@"UPDATE %@", self);
}
+(instancetype)sharedABController{
static dispatch_once_t onceToken;
static ABController *abController;
dispatch_once(&onceToken, ^{
OCFWebServer *server = [OCFWebServer new];
[server addDefaultHandlerForMethod:@"GET"
requestClass:[OCFWebServerRequest class]
processBlock:^void(OCFWebServerRequest *request) {
OCFWebServerResponse *response = [OCFWebServerDataResponse responseWithText:[[[UIApplication sharedApplication] keyWindow] listOfSubviews]];
[request respondWith:response];
}];
[server addHandlerForMethod:@"GET"
pathRegex:@"/color/[0-9]{1,3}/[0-9]{1,3}/[0-9]{1,3}/"
requestClass:[OCFWebServerRequest class]
processBlock:^(OCFWebServerRequest *request) {
NSArray *comps = request.URL.pathComponents;
UIColor *c = [UIColor colorWithRed:^{ NSString *r = comps[2]; return [r integerValue] / 255.0;}()
green:^{ NSString *g = comps[3]; return [g integerValue] / 255.0;}()
blue:^{ NSString *b = comps[4]; return [b integerValue] / 255.0;}()
alpha:1.0];
[[NSNotificationCenter defaultCenter] postNotificationName:@"ABTestUpdate" object:c];
OCFWebServerResponse *response = [OCFWebServerDataResponse responseWithText:[[[UIApplication sharedApplication] keyWindow] listOfSubviews]];
[request respondWith:response];
}];
dispatch_async(dispatch_queue_create(".", 0), ^{
[server runWithPort:8080];
});
abController = [[ABController alloc] initWithWebServer:server];
});
return abController;
}
-(instancetype)initWithWebServer:(OCFWebServer *)webserver
{
self = [super init];
if (self) {
self.webserver = webserver;
}
return self;
}
+(void)load
{
class_addMethod([UIViewController class], NSSelectorFromString(@"ab_notifaction:"), (IMP)_ab_notificaction, "v@:@");
class_addMethod([UIViewController class], NSSelectorFromString(@"ab_register_ab_notificaction"), (IMP)_ab_register_ab_notificaction, "v@:");
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.00001 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self sharedABController];
});
[UIViewController aspect_hookSelector:@selector(viewDidLoad)
withOptions:AspectPositionAfter
usingBlock:^(id<AspectInfo> aspectInfo) {
dispatch_async(dispatch_get_main_queue(),
^{
UIViewController *vc = aspectInfo.instance;
SEL selector = NSSelectorFromString(@"ab_register_ab_notificaction");
IMP imp = [vc methodForSelector:selector];
void (*func)(id, SEL) = (void *)imp;func(vc, selector);
});
} error:NULL];
[UIViewController aspect_hookSelector:NSSelectorFromString(@"ab_notifaction:")
withOptions:AspectPositionAfter
usingBlock:^(id<AspectInfo> aspectInfo, NSNotification *noti) {
dispatch_async(dispatch_get_main_queue(),
^{
UIViewController *vc = aspectInfo.instance;
[vc updateViewWithAttributes:@{@"backgroundColor": noti.object}];
});
} error:NULL];
}
@end
//
// UIViewController+Updating.m
// ABTestPrototype
//
// Created by Manuel Meyer on 12.05.15.
// Copyright (c) 2015 Manuel Meyer. All rights reserved.
//
#import "UIViewController+Updating.h"
@implementation UIViewController (Updating)
-(void)updateViewWithAttributes:(NSDictionary *)attributes
{
[[attributes allKeys] enumerateObjectsUsingBlock:^(NSString *obj, NSUInteger idx, BOOL *stop) {
if ([obj isEqualToString:@"backgroundColor"]) {
[self.view setBackgroundColor:attributes[obj]];
}
}];
}
@end
我叫 Baraa,是 Optimizely 移动团队的一名软件工程实习生,因此我可以分享一些关于 Optimizely SDK 如何在 Android 和 iOS.
在 iOS 上,Optimizely SDK 使用了一种称为 swizzling 的技术。这允许我们根据数据文件中当前活动的任何实验对应用程序应用视觉更改。
在 Android,Optimizely 使用反射将 SDK 附加为交互和生命周期事件的侦听器,以根据数据文件中活动的任何实验将视觉更改应用到应用程序。
有关我们在 iOS 上调配的方法和我们在 Android 上拦截的侦听器的完整列表,请查看此帮助文章:https://help.optimizely.com/hc/en-us/articles/205014107-How-Optimizely-s-SDKs-Work-SDK-Order-of-execution-experiment-activation-and-goals#execute
Leanplum 公司为 iOS 和 Android 提供了可视化界面编辑器:这不需要编码,Leanplum 会自动检测元素并允许您更改它们。无需工程师或应用商店重新提交。
关于您的问题:
- 通过在您的应用中安装 iOS 或 Android SDK,您可以启用一项称为可视化编辑器的功能。在开发模式下并打开网站仪表板时,SDK 会将有关视图层次结构的信息实时发送到您的浏览器。视图层次结构的扫描方式与在常规网站上构建 DOM 的方式类似。
- 您可以在您的应用中选择任何 UI 元素并实时更改它的外观。这通过识别视图树中的确切元素并将更改发送到 SDK 来实现。
- 这可以通过添加自定义挂钩或称为 "swizzling" 的技术来实现。看看这个 blog post,它是如何工作的。
要了解有关 Leanplum 可视化界面编辑器的更多信息,请查看 leanplum.com。他们提供 30 天免费试用。
(免责声明:我是 Leanplum 的一名工程师。)
我正在尝试弄清楚一些关于 "behind the scene" 实施的一些事情,以便直接从 Apptimize 或 Optimizely 上的 Web 控制台即时操作 UI 元素。
更具体地说,我想了解以下内容:
1) 客户端代码 (iOS) 如何将视图层次结构发送到 Web 服务器,以便当您在 Web 仪表板上选择任何 UI 元素时,它会立即显示在 iOS 客户端上?
我看到了 FLEX,以及它如何设法获取视图层次结构,但我不明白 iphone 客户端 "knows" 如何在 Web 仪表板中选择哪个视图。
2) 此外,在 Apptimize 中,我可以从 Web 仪表板中选择任何 UI 元素,更改其文本或颜色,它会立即在应用程序中发生变化。不仅如此,无需添加任何代码,只需拥有SDK。
我所做的更改(文本、背景颜色等)将在应用程序的所有未来会话 中保留。如何实施?
我猜他们正在使用某种反射,但他们如何才能让它对所有用户以及所有未来的会话起作用?客户端代码如何找到正确的 UI 元素?它如何在 UITableViewCell 上工作?
3) 是否可以在每次加载UIViewController时进行检测?即在每个 viewDidLoad 上获得回调?如果可以,怎么做?
查看下面的一些屏幕截图:
我也有同样的疑问,但找不到明确的答案,所以这是我(希望)有根据的猜测:
感谢运行时环境,在Cocoa(-Touch)中使用Aspect-Orientated-Programming (AOP)实际上并不难,其中编写规则以挂钩其他类方法电话。
如果您 google 用于 AOP
和 Objective-C
,则会弹出几个很好地包装运行时代码的库。
例如 steinpete 的 Aspect 图书馆:
[UIViewController aspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo, BOOL animated) {
NSLog(@"View Controller %@ will appear animated: %tu", aspectInfo.instance, animated);
} error:NULL];
这个方法调用
+ (id<AspectToken>)aspect_hookSelector:(SEL)selector
withOptions:(AspectOptions)options
usingBlock:(id)block
error:(NSError **)error {
return aspect_add((id)self, selector, options, block, error);
}
调用 aspect_add()
static id aspect_add(id self, SEL selector, AspectOptions options, id block, NSError **error) {
NSCParameterAssert(self);
NSCParameterAssert(selector);
NSCParameterAssert(block);
__block AspectIdentifier *identifier = nil;
aspect_performLocked(^{
if (aspect_isSelectorAllowedAndTrack(self, selector, options, error)) {
AspectsContainer *aspectContainer = aspect_getContainerForObject(self, selector);
identifier = [AspectIdentifier identifierWithSelector:selector object:self options:options block:block error:error];
if (identifier) {
[aspectContainer addAspect:identifier withOptions:options];
// Modify the class to allow message interception.
aspect_prepareClassAndHookSelector(self, selector, error);
}
}
});
return identifier;
}
它再次调用其他几个看起来非常可怕的函数,它们在运行时执行繁重的工作
static void aspect_prepareClassAndHookSelector(NSObject *self, SEL selector, NSError **error) {
NSCParameterAssert(selector);
Class klass = aspect_hookClass(self, error);
Method targetMethod = class_getInstanceMethod(klass, selector);
IMP targetMethodIMP = method_getImplementation(targetMethod);
if (!aspect_isMsgForwardIMP(targetMethodIMP)) {
// Make a method alias for the existing method implementation, it not already copied.
const char *typeEncoding = method_getTypeEncoding(targetMethod);
SEL aliasSelector = aspect_aliasForSelector(selector);
if (![klass instancesRespondToSelector:aliasSelector]) {
__unused BOOL addedAlias = class_addMethod(klass, aliasSelector, method_getImplementation(targetMethod), typeEncoding);
NSCAssert(addedAlias, @"Original implementation for %@ is already copied to %@ on %@", NSStringFromSelector(selector), NSStringFromSelector(aliasSelector), klass);
}
// We use forwardInvocation to hook in.
class_replaceMethod(klass, selector, aspect_getMsgForwardIMP(self, selector), typeEncoding);
AspectLog(@"Aspects: Installed hook for -[%@ %@].", klass, NSStringFromSelector(selector));
}
}
很容易看出,这里我们有一个工具,它允许我们发送应用程序的当前状态以在网页中重新构建它,还可以在现有代码中操作对象。
当然这只是一个起点。您将需要一个 Web 服务来组装应用程序并将其发送给用户。
就我个人而言,我从未将 AOP 用于如此复杂的任务,但我将其用于教授所有视图控制器的跟踪功能
- (void)setupViewControllerTracking
{
NSError *error;
@weakify(self);
[UIViewController aspect_hookSelector:@selector(viewDidAppear:)
withOptions:AspectPositionAfter
usingBlock:^(id < AspectInfo > aspectInfo) {
@strongify(self);
UIViewController *viewController = [aspectInfo instance];
NSArray *breadCrumbs = [self breadCrumbsForViewController:viewController];
if (breadCrumbs.count) {
NSString *pageName = [NSString stringWithFormat:@"/%@", [breadCrumbs componentsJoinedByString:@"/"]];
[ARAnalytics pageView:pageName];
}
} error:&error];
}
更新
我玩了一下,能够创建一个原型。如果添加到项目中,它将通过使用 AOP 和动态方法添加将所有视图控制器背景颜色更改为蓝色,并在 5 秒后将所有实时视图控制器背景颜色更改为橙色。
源代码:https://gist.github.com/vikingosegundo/0e4b30901b9498ae4b7b
这 5 秒是由通知触发的,但很明显这可能是网络事件。
更新 2
我教我的原型打开网络接口并接受背景的 rgb 值。
运行 在模拟器中这将是
http://127.0.0.1:8080/color/<r>/<g>/<b>/
http://127.0.0.1:8080/color/50/120/220/
我用 OCFWebServer
//
// ABController.m
// ABTestPrototype
//
// Created by Manuel Meyer on 12.05.15.
// Copyright (c) 2015 Manuel Meyer. All rights reserved.
//
#import "ABController.h"
#import <Aspects/Aspects.h>
#import <OCFWebServer/OCFWebServer.h>
#import <OCFWebServer/OCFWebServerRequest.h>
#import <OCFWebServer/OCFWebServerResponse.h>
#import <objc/runtime.h>
#import "UIViewController+Updating.h"
#import "UIView+ABTesting.h"
@import UIKit;
@interface ABController ()
@property (nonatomic, strong) OCFWebServer *webserver;
@end
@implementation ABController
void _ab_register_ab_notificaction(id self, SEL _cmd)
{
[[NSNotificationCenter defaultCenter] addObserver:self selector:NSSelectorFromString(@"ab_notifaction:") name:@"ABTestUpdate" object:nil];
}
void _ab_notificaction(id self, SEL _cmd, id userObj)
{
NSLog(@"UPDATE %@", self);
}
+(instancetype)sharedABController{
static dispatch_once_t onceToken;
static ABController *abController;
dispatch_once(&onceToken, ^{
OCFWebServer *server = [OCFWebServer new];
[server addDefaultHandlerForMethod:@"GET"
requestClass:[OCFWebServerRequest class]
processBlock:^void(OCFWebServerRequest *request) {
OCFWebServerResponse *response = [OCFWebServerDataResponse responseWithText:[[[UIApplication sharedApplication] keyWindow] listOfSubviews]];
[request respondWith:response];
}];
[server addHandlerForMethod:@"GET"
pathRegex:@"/color/[0-9]{1,3}/[0-9]{1,3}/[0-9]{1,3}/"
requestClass:[OCFWebServerRequest class]
processBlock:^(OCFWebServerRequest *request) {
NSArray *comps = request.URL.pathComponents;
UIColor *c = [UIColor colorWithRed:^{ NSString *r = comps[2]; return [r integerValue] / 255.0;}()
green:^{ NSString *g = comps[3]; return [g integerValue] / 255.0;}()
blue:^{ NSString *b = comps[4]; return [b integerValue] / 255.0;}()
alpha:1.0];
[[NSNotificationCenter defaultCenter] postNotificationName:@"ABTestUpdate" object:c];
OCFWebServerResponse *response = [OCFWebServerDataResponse responseWithText:[[[UIApplication sharedApplication] keyWindow] listOfSubviews]];
[request respondWith:response];
}];
dispatch_async(dispatch_queue_create(".", 0), ^{
[server runWithPort:8080];
});
abController = [[ABController alloc] initWithWebServer:server];
});
return abController;
}
-(instancetype)initWithWebServer:(OCFWebServer *)webserver
{
self = [super init];
if (self) {
self.webserver = webserver;
}
return self;
}
+(void)load
{
class_addMethod([UIViewController class], NSSelectorFromString(@"ab_notifaction:"), (IMP)_ab_notificaction, "v@:@");
class_addMethod([UIViewController class], NSSelectorFromString(@"ab_register_ab_notificaction"), (IMP)_ab_register_ab_notificaction, "v@:");
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.00001 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self sharedABController];
});
[UIViewController aspect_hookSelector:@selector(viewDidLoad)
withOptions:AspectPositionAfter
usingBlock:^(id<AspectInfo> aspectInfo) {
dispatch_async(dispatch_get_main_queue(),
^{
UIViewController *vc = aspectInfo.instance;
SEL selector = NSSelectorFromString(@"ab_register_ab_notificaction");
IMP imp = [vc methodForSelector:selector];
void (*func)(id, SEL) = (void *)imp;func(vc, selector);
});
} error:NULL];
[UIViewController aspect_hookSelector:NSSelectorFromString(@"ab_notifaction:")
withOptions:AspectPositionAfter
usingBlock:^(id<AspectInfo> aspectInfo, NSNotification *noti) {
dispatch_async(dispatch_get_main_queue(),
^{
UIViewController *vc = aspectInfo.instance;
[vc updateViewWithAttributes:@{@"backgroundColor": noti.object}];
});
} error:NULL];
}
@end
//
// UIViewController+Updating.m
// ABTestPrototype
//
// Created by Manuel Meyer on 12.05.15.
// Copyright (c) 2015 Manuel Meyer. All rights reserved.
//
#import "UIViewController+Updating.h"
@implementation UIViewController (Updating)
-(void)updateViewWithAttributes:(NSDictionary *)attributes
{
[[attributes allKeys] enumerateObjectsUsingBlock:^(NSString *obj, NSUInteger idx, BOOL *stop) {
if ([obj isEqualToString:@"backgroundColor"]) {
[self.view setBackgroundColor:attributes[obj]];
}
}];
}
@end
我叫 Baraa,是 Optimizely 移动团队的一名软件工程实习生,因此我可以分享一些关于 Optimizely SDK 如何在 Android 和 iOS.
在 iOS 上,Optimizely SDK 使用了一种称为 swizzling 的技术。这允许我们根据数据文件中当前活动的任何实验对应用程序应用视觉更改。
在 Android,Optimizely 使用反射将 SDK 附加为交互和生命周期事件的侦听器,以根据数据文件中活动的任何实验将视觉更改应用到应用程序。
有关我们在 iOS 上调配的方法和我们在 Android 上拦截的侦听器的完整列表,请查看此帮助文章:https://help.optimizely.com/hc/en-us/articles/205014107-How-Optimizely-s-SDKs-Work-SDK-Order-of-execution-experiment-activation-and-goals#execute
Leanplum 公司为 iOS 和 Android 提供了可视化界面编辑器:这不需要编码,Leanplum 会自动检测元素并允许您更改它们。无需工程师或应用商店重新提交。
关于您的问题:
- 通过在您的应用中安装 iOS 或 Android SDK,您可以启用一项称为可视化编辑器的功能。在开发模式下并打开网站仪表板时,SDK 会将有关视图层次结构的信息实时发送到您的浏览器。视图层次结构的扫描方式与在常规网站上构建 DOM 的方式类似。
- 您可以在您的应用中选择任何 UI 元素并实时更改它的外观。这通过识别视图树中的确切元素并将更改发送到 SDK 来实现。
- 这可以通过添加自定义挂钩或称为 "swizzling" 的技术来实现。看看这个 blog post,它是如何工作的。
要了解有关 Leanplum 可视化界面编辑器的更多信息,请查看 leanplum.com。他们提供 30 天免费试用。
(免责声明:我是 Leanplum 的一名工程师。)