大家好! 我叫Sasha Zimin,我在
Badoo伦敦办公室担任iOS开发人员。 Badoo与产品经理之间有着非常密切的关系,我习惯了测试关于该产品的所有假设。 因此,我开始为我的项目编写拆分测试。
本文将要讨论的框架是出于两个目的而编写的。 首先,为了避免可能的错误,最好是分析系统中没有数据比不正确的数据(甚至是可能被错误解释并破坏柴火的数据)更好。 其次,简化每个后续测试的实施。 但是,让我们从拆分测试开始。
如今,有数百万种应用程序可以满足用户的大多数需求,因此每天创建新的竞争产品变得更加困难。 这导致许多公司和初创公司首先进行各种研究和实验,以找出哪些功能可以使他们的产品更好,哪些功能可以被放弃。
进行此类实验的主要工具之一是拆分测试(或A / B测试)。 在本文中,我将介绍如何在Swift上实现它。
所有项目演示都可
在此处获得 。 如果您已有关于A / B测试的想法,则可以直接
转到代码 。
拆分测试简介
拆分测试或A / B测试(此术语并不总是正确的,因为您可以有两个以上的参与者组),是一种检查不同用户组上产品的不同版本以了解哪个版本更好的方法。 您可以在
Wikipedia中阅读有关它的
内容 ,例如,在本文中有真实的示例。
在Badoo,我们同时运行许多拆分测试。 例如,一旦我们确定应用程序中的用户个人资料页面看起来已过时,并且还希望改善用户与某些横幅的交互。 因此,我们将测试分为三组进行:
- 旧资料
- 新配置文件版本1
- 新配置文件版本2
如您所见,我们提供了三个选项,更像是A / B / C测试(这就是为什么我们更喜欢使用术语“分离测试”的原因)。
因此,不同的用户看到了他们的个人资料:
在产品管理器控制台中,我们有四个随机组成且数量相同的用户组:
也许您问为什么我们要有control和control_check(如果control_check是控制组逻辑的副本)? 答案很简单:任何更改都会影响许多指标,因此我们永远不能绝对确定特定更改是拆分测试的结果,而不是其他操作的结果。
如果您认为某些指标由于拆分测试而发生了变化,则应仔细检查在control和control_check组中它们是否相同。
如您所见,用户的意见可能有所不同,但是经验证据是明确的证据。 产品经理团队分析结果并理解为什么一个选择比另一个选择更好。
拆分测试和Swift
目标:
- 为客户端创建一个库(不使用服务器)。
- 意外生成所选用户选项后,将其保存到永久存储中。
- 将有关每个拆分测试的所选选项的报告发送到分析服务。
- 充分利用Swift的功能。
PS使用这种库对客户端部分进行拆分测试有其优点和缺点。 主要优点是您不需要服务器基础结构或专用服务器。 缺点是,如果在实验过程中出了点问题,您必须在App Store中下载新版本才能回滚。
关于实现的几句话:
- 在实验过程中,将根据同样可能的原则随机选择用户的选项。
- 拆分测试服务可以使用:
- 将任何数据存储(例如,UserDefaults,Realm,SQLite或Core Data)作为依赖项,并将分配给用户的值(其变体的值)保存到其中。
- 任何分析服务(例如Amplitude或Facebook Analytics)都作为依赖项,并在用户遇到拆分测试时发送当前版本。
这是将来的类的图:
所有拆分测试都将使用
SplitTestProtocol呈现,并且每个拆分测试都将具有几个选项(组),这些选项将在
SplitTestGroupProtocol中呈现。
拆分测试应该能够告知分析人员当前版本,因此它将具有
AnalyticsProtocol作为依赖项。
服务
SplitTestingService将保存,生成选项并管理所有拆分测试。 是他从存储中下载用户的当前版本(由
StorageProtocol确定),并将
AnalyticsProtocol传递给
SplitTestProtocol 。
让我们开始使用
AnalyticsProtocol和
StorageProtocol依赖关系编写代码:
protocol AnalyticsServiceProtocol { func setOnce(value: String, for key: String) } protocol StorageServiceProtocol { func save(string: String?, for key: String) func getString(for key: String) -> String? }
分析的作用是记录一次事件。 例如,当用户
A看到带有该按钮的屏幕时,要在
button_color拆分测试期间修复该用户
A在
蓝色组中。
存储库的作用是为当前用户保存一个特定选项(在
SplitTestingService生成此选项之后),然后在程序每次访问此拆分测试时将其读回。
因此,让我们看一下
SplitTestGroupProtocol ,它描述了特定拆分测试的一组选项:
protocol SplitTestGroupProtocol: RawRepresentable where RawValue == String { static var testGroups: [Self] { get } }
由于
RawRepresentable的RawValue是字符串,因此您可以轻松地从字符串创建变体或将其转换回字符串,这对于使用分析和存储非常方便。
SplitTestGroupProtocol还包含一个testGroups数组,它可以指示当前选项的组成(该数组也将用于从可用选项中随机生成)。
这是
SplitTestProtocol拆分测试
本身的基本原型:
protocol SplitTestProtocol { associatedtype GroupType: SplitTestGroupProtocol static var identifier: String { get } var currentGroup: GroupType { get } var analytics: AnalyticsServiceProtocol { get } init(currentGroup: GroupType, analytics: AnalyticsServiceProtocol) } extension SplitTestProtocol { func hitSplitTest() { self.analytics.setOnce(value: self.currentGroup.rawValue, for: Self.analyticsKey) } static var analyticsKey: String { return "split_test-\(self.identifier)" } static var dataBaseKey: String { return "split_test_database-\(self.identifier)" } }
SplitTestProtocol包含:
- 一个GroupType类型,该类型实现SplitTestGroupProtocol协议,以表示定义一组选项的类型。
- 分析和存储键的字符串值标识符 。
- currentGroup变量,用于记录SplitTestProtocol的特定实例。
- hitSplitTest方法的Google Analytics(分析)依赖性。
- 还有hitSplitTest方法,该方法通知分析人员用户看到了拆分测试的结果。
hitSplitTest方法使您可以确保用户不仅使用特定版本,还可以查看测试结果。 如果将未访问购物区的用户标记为“ saw_red_button_on_purcahse_screen”,这将使结果失真。
现在我们可以使用
SplitTestingService了 :
protocol SplitTestingServiceProtocol { func fetchSplitTest<Value: SplitTestProtocol>(_ splitTestType: Value.Type) -> Value } class SplitTestingService: SplitTestingServiceProtocol { private let analyticsService: AnalyticsServiceProtocol private let storage: StorageServiceProtocol init(analyticsService: AnalyticsServiceProtocol, storage: StorageServiceProtocol) { self.analyticsService = analyticsService self.storage = storage } func fetchSplitTest<Value: SplitTestProtocol>(_ splitTestType: Value.Type) -> Value { if let value = self.getGroup(splitTestType) { return Value(currentGroup: value, analytics: self.analyticsService) } let randomGroup = self.randomGroup(Value.self) self.saveGroup(splitTestType, group: randomGroup) return Value(currentGroup: randomGroup, analytics: self.analyticsService) } private func saveGroup<Value: SplitTestProtocol>(_ splitTestType: Value.Type, group: Value.GroupType) { self.storage.save(string: group.rawValue, for: Value.dataBaseKey) } private func getGroup<Value: SplitTestProtocol>(_ splitTestType: Value.Type) -> Value.GroupType? { guard let stringValue = self.storage.getString(for: Value.dataBaseKey) else { return nil } return Value.GroupType(rawValue: stringValue) } private func randomGroup<Value: SplitTestProtocol>(_ splitTestType: Value.Type) -> Value.GroupType { let count = Value.GroupType.testGroups.count let random = Int.random(lower: 0, count - 1) return Value.GroupType.testGroups[random] } }
PS在此类中,我们使用Int.random函数,该函数取自
在这里 ,但是在Swift 4.2中它已经默认内置。
此类包含一个公共
fetchSplitTest方法和三个私有方法:
saveGroup ,
getGroup ,
randomGroup 。
randomGroup方法为选定的拆分测试生成一个随机变量,而getGroup和saveGroup允许您为当前用户的特定拆分测试保存或加载变量。
此类的主要公共函数是fetchSplitTest:它尝试从持久性存储中返回当前版本,如果不成功,则在返回之前生成并保存一个随机版本。
现在我们准备创建我们的第一个拆分测试:
final class ButtonColorSplitTest: SplitTestProtocol { static var identifier: String = "button_color" var currentGroup: ButtonColorSplitTest.Group var analytics: AnalyticsServiceProtocol init(currentGroup: ButtonColorSplitTest.Group, analytics: AnalyticsServiceProtocol) { self.currentGroup = currentGroup self.analytics = analytics } typealias GroupType = Group enum Group: String, SplitTestGroupProtocol { case red = "red" case blue = "blue" case darkGray = "dark_gray" static var testGroups: [ButtonColorSplitTest.Group] = [.red, .blue, .darkGray] } } extension ButtonColorSplitTest.Group { var color: UIColor { switch self { case .blue: return .blue case .red: return .red case .darkGray: return .darkGray } } }
它看起来令人印象深刻,但请放心:只要将SplitTestProtocol作为单独的类实现,编译器就会要求您实现所有必要的属性。
这里的重要部分是
枚举组类型。 您应该将所有组放入其中(在我们的示例中为红色,蓝色和深灰色),并在此处定义字符串值以确保正确传输到分析。
我们还有一个扩展
ButtonColorSplitTest.Group ,使您可以充分利用Swift的潜力。 现在让我们为
AnalyticsProtocol和
StorageProtocol创建对象:
extension UserDefaults: StorageServiceProtocol { func save(string: String?, for key: String) { self.set(string, forKey: key) } func getString(for key: String) -> String? { return self.object(forKey: key) as? String } }
对于
StorageProtocol,我们将使用UserDefaults类,因为它易于实现,但是在您的项目中,您可以使用任何其他持久性存储(例如,我为自己选择了Keychain,因为它即使在删除后也可以为用户保存组)。
在此示例中,我将创建一个虚拟的分析类,但是您可以在项目中使用真实的分析。 例如,您可以使用
振幅服务。
// Dummy class for example, use something real, like Amplitude class Analytics { func logOnce(property: NSObject, for key: String) { let storageKey = "example.\(key)" if UserDefaults.standard.object(forKey: storageKey) == nil { print("Log once value: \(property) for key: \(key)") UserDefaults.standard.set("", forKey: storageKey) // String because of simulator bug } } } extension Analytics: AnalyticsServiceProtocol { func setOnce(value: String, for key: String) { self.logOnce(property: value as NSObject, for: key) } }
现在我们可以使用拆分测试了:
let splitTestingService = SplitTestingService(analyticsService: Analytics(), storage: UserDefaults.standard) let buttonSplitTest = splitTestingService.fetchSplitTest(ButtonColorSplitTest.self) self.button.backgroundColor = buttonSplitTest.currentGroup.color buttonSplitTest.hitSplitTest()
只需创建您自己的实例,提取拆分测试并使用它即可。 概括使您可以调用
buttonSplitTest.currentGroup.color.
首次使用时,您会看到类似(
记录一次值 )
:键:dark_gray的split_test-button_color ,并且如果您不从设备中删除应用程序,则每次启动时该按钮都是相同的。
实现这种库的过程需要一些时间,但是在那之后,将在几分钟内创建项目中的每个新拆分测试。
这是在实际应用中使用引擎的示例:在分析中,我们通过复杂性系数和购买游戏货币的可能性对用户进行了细分。

从未遇到此困难因素(无)的人可能根本不玩游戏,也不在游戏中购买任何东西(这是合乎逻辑的),这就是为什么在以下情况下一次将拆分测试的结果(生成的版本)发送到服务器很重要的原因用户真的遇到了您的测试。
没有困难因素,只有2%的用户购买了游戏币。 由于比例很小,购买量已经达到3%。 由于难度系数很高,因此有4%的玩家购买了该货币。 这意味着您可以继续增加系数并观察数字。 :)
如果您有兴趣以最大的可靠性分析结果,建议您使用
此工具 。
感谢出色的团队帮助我撰写本文(特别是
Igor ,
Kelly和
Hiro )。
整个演示项目可在
此链接上获得 。