Как проверить гипотезы и заработать на Swift с помощью сплит-тестов
Всем привет! Меня зовут Саша Зимин, я работаю iOS-разработчиком в лондонском офисе Badoo. В Badoo очень тесное взаимодействие с продуктовыми менеджерами, и я перенял у них привычку проверять все гипотезы, которые возникают у меня относительно продукта. Так, я начал писать сплит-тесты для своих проектов.
Фреймворк, о котором пойдет речь в этой статье, был написан с двумя целями. Во-первых, чтобы избежать возможных ошибок, ведь лучше отсутствие данных в системе аналитики, чем данные некорректные (или вообще данные, которые можно неверно интерпретировать и наломать дров). Во-вторых, чтобы упростить внедрение каждого последующего теста. Но начнём, пожалуй, с того, что представляют из себя сплит-тесты.
В наше время существуют миллионы приложений, решающих большинство потребностей пользователей, поэтому с каждым днём становится всё сложнее создавать новые конкурентоспособные продукты. Это привело к тому, что многие компании и стартапы сначала проводят различные исследования и эксперименты, чтобы выяснить, какие функции делают их продукт лучше, а без каких можно обойтись.
Одним из основных инструментов для проведения таких экспериментов является сплит-тестирование (или A/B-тестирование). В этой статье я расскажу, как его можно реализовать на Swift.
Все демонстрационные материалы проекта доступны по ссылке. Если вы уже имеете представление об A/B-тестировании, то можете сразу переходить к коду.
Сплит-тестирование, или A/B-тестирование (этот термин не всегда корректен, ведь у вас может быть и более двух групп участников), является способом проверки различных версий продукта на разных группах пользователей с целью понять, какая версия лучше. Почитать об этом можно в «Википедии» или, например, в этой статье с реальными примерами.
В Badoo мы проводим одновременно много сплит-тестов. Например, однажды мы решили, что страница профиля пользователя в нашем приложении выглядит устаревшей, а также захотели улучшить взаимодействие пользователей с некоторыми баннерами. Поэтому мы запустили сплит-тестирование с тремя группами:
- Старый профиль
- Новый профиль, версия 1
- Новый профиль, версия 2
Как видите, у нас было три варианта, больше похоже на A/B/C-тестирование (и именно поэтому мы предпочитаем использовать термин «сплит-тестирование»).
Так разные пользователи видели свои профили:
В консоли Product Manager у нас было четыре группы пользователей, сформированных случайным образом и имеющих одинаковую численность:
Возможно, вы спросите, почему у нас есть control и control_check (если control_check — это копия логики группы control)? Ответ очень прост: любое изменение влияет на множество показателей, поэтому мы никогда не можем быть абсолютно уверены в том, что то или иное изменение является результатом проведения сплит-теста, а не других действий.
Если вы считаете, что какие-то показатели изменились из-за сплит-теста, то следует дважды проверить, что внутри групп control и control_check они одинаковы.
Как видите, мнения пользователей могут отличаться, но эмпирические данные являются наглядным доказательством. Команда продуктовых менеджеров анализирует результаты и понимает, почему один вариант лучше другого.
Цели:
- Создать библиотеку для клиентской части (без использования сервера).
- Сохранять выбранный вариант юзера в постоянном хранилище после того, как он был случайно сгенерирован.
- Отправлять отчёты о выбранных вариантах для каждого сплит-теста в сервис аналитики.
- Как можно шире использовать возможности Swift.
P.S. Использование такой библиотеки для сплит-тестирования клиентской части имеет свои преимущества и недостатки. Главное преимущество заключается в том, что вам не нужно иметь серверную инфраструктуру или выделенный сервер. А недостаток — в том, что, если в ходе эксперимента что-то пойдёт не так, вы не сможете откатиться назад без загрузки новой версии в App Store.
Несколько слов о реализации:
- При проведении эксперимента вариант для пользователя выбирается случайным образом по равновероятному принципу.
- Сервис сплит-тестирования может использовать:
- Любое хранилище данных (например, UserDefaults, Realm, SQLite или Core Data) в качестве зависимости и сохранять в него присвоенное пользователю значение (значение его варианта).
- Любой сервис аналитики (например, Amplitude или Facebook Analytics) в качестве зависимости и отправлять текущий вариант в тот момент, когда пользователь столкнётся со сплит-тестом.
Вот схема будущих классов:
Все сплит-тесты будут представлены с помощью SplitTestProtocol, и у каждого из них будет несколько вариантов (групп), которые будут представлены в SplitTestGroupProtocol.
Сплит-тест должен иметь возможность информировать аналитику о текущем варианте, поэтому в качестве зависимости у него будет AnalyticsProtocol.
Cервис 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 находится в группе blue в процессе сплит-теста button_color, когда видит экран с этой кнопкой.
Роль хранилища заключается в сохранении определённого варианта для текущего юзера (после того, как SplitTestingService сгенерировал этот вариант) и его последующем считывании при каждом обращении программы к этому сплит-тесту.
Итак, давайте посмотрим на SplitTestGroupProtocol, который характеризует набор вариантов для определённого сплит-теста:
protocol SplitTestGroupProtocol: RawRepresentable where RawValue == String {
static var testGroups: [Self] { get }
}
Поскольку RawRepresentable where 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 для представления типа, определяющего набор вариантов.
- Строковое значение identifier для аналитики и ключей хранилища.
- Переменная currentGroup для записи конкретного экземпляра SplitTestProtocol.
- Зависимость analytics для метода hitSplitTest.
- И метод hitSplitTest, который сообщает аналитике о том, что пользователь увидел результат сплит-теста.
Метод hitSplitTest позволяет удостовериться в том, что пользователи не просто находятся в определённом варианте, но и увидели результат тестирования. Если пометить пользователя, не посещавшего раздел покупок, как «saw_red_button_on_purcahse_screen», это исказит результаты.
Теперь у нас всё готово для SplitTestingService:
protocol SplitTestingServiceProtocol {
func fetchSplitTest(_ 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(_ 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(_ splitTestType: Value.Type, group: Value.GroupType) {
self.storage.save(string: group.rawValue, for: Value.dataBaseKey)
}
private func getGroup(_ 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(_ splitTestType: Value.Type) -> Value.GroupType {
let count = Value.GroupType.testGroups.count
let random = Int.random(lower: 0, count - 1)
return Value.GroupType.testGroups[random]
}
}
P.S. В этом классе мы используем функцию 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 отдельным классом, компилятор попросит реализовать все необходимые свойства.
Важная часть здесь — тип enum Group. В него вы должны поместить все свои группы (в нашем примере это red, blue и darkGray), и здесь же определить строковые значения, чтобы обеспечить корректную передачу в аналитику.
Также у нас есть расширение 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, так как оно сохраняет группу за пользователем даже после удаления).
В этом примере я создам класс фиктивной аналитики, но в своём проекте вы можете использовать настоящую аналитику. Например, можно воспользоваться сервисом Amplitude.
// 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.
Во время первого использования вы можете увидеть что-то вроде (Log once value): split_test-button_color for key: dark_gray, и, если вы не удалите приложение с устройства, кнопка будет одинаковой при каждом запуске.
Процесс реализации такой библиотеки занимает некоторое время, но после этого каждый новый сплит-тест внутри вашего проекта будет создаваться за пару минут.
Вот пример использования движка в реальном приложении: в аналитике мы сегментировали пользователей по коэффициенту сложности и вероятности покупки игровой валюты.
Люди, которые никогда не сталкивались с этим коэффициентом сложности (none), вероятно, вообще не играют и ничего не покупают в играх (что логично), и именно поэтому важно отправлять на сервер результат (сгенерированный вариант) сплит-тестирования в тот момент, когда пользователи действительно сталкиваются с вашим тестом.
Без коэффициента сложности только 2% пользователей покупали игровую валюту. С небольшим коэффициентом покупки совершали уже 3%. И с большим коэффициентом сложности 4% игроков купили валюту. Это значит, что можно продолжить увеличивать коэффициент и наблюдать за цифрами. :)
Если вам интересно анализировать результаты с максимальной достоверностью, то советую использовать этот инструмент.
Спасибо замечательной команде, которая помогла мне в работе над этой статьёй (особенно Игорю, Келли и Хайро).
Весь демонстрационный проект доступен по этой ссылке.