Как проверить гипотезы и заработать на Swift с помощью сплит-тестов
Всем привет! Меня зовут Саша Зимин, я работаю iOS-разработчиком в лондонском офисе Badoo. В Badoo очень тесное взаимодействие с продуктовыми менеджерами, и я перенял у них привычку проверять все гипотезы, которые возникают у меня относительно продукта. Так, я начал писать сплит-тесты для своих проектов.
Во-первых, чтобы избежать возможных ошибок, ведь лучше отсутствие данных в системе аналитики, чем данные некорректные (или вообще данные, которые можно неверно интерпретировать и наломать дров). Фреймворк, о котором пойдет речь в этой статье, был написан с двумя целями. Но начнём, пожалуй, с того, что представляют из себя сплит-тесты.
В наше время существуют миллионы приложений, решающих большинство потребностей пользователей, поэтому с каждым днём становится всё сложнее создавать новые конкурентоспособные продукты. Во-вторых, чтобы упростить внедрение каждого последующего теста. Это привело к тому, что многие компании и стартапы сначала проводят различные исследования и эксперименты, чтобы выяснить, какие функции делают их продукт лучше, а без каких можно обойтись.
В этой статье я расскажу, как его можно реализовать на Swift. Одним из основных инструментов для проведения таких экспериментов является сплит-тестирование (или A/B-тестирование).
Если вы уже имеете представление об 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.
Именно он загружает текущий вариант пользователя из хранилища, которая определяется StorageProtocol, а также передаёт AnalyticsProtocol в SplitTestProtocol.
Cервис SplitTestingService будет сохранять, генерировать варианты и управлять всеми сплит-тестами.
Начнём писать код с зависимостей 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
}
Поскольку 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, который сообщает аналитике о том, что пользователь увидел результат сплит-теста.
Если пометить пользователя, не посещавшего раздел покупок, как «saw_red_button_on_purcahse_screen», это исказит результаты. Метод hitSplitTest позволяет удостовериться в том, что пользователи не просто находятся в определённом варианте, но и увидели результат тестирования.
Теперь у нас всё готово для 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] }
}
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 отдельным классом, компилятор попросит реализовать все необходимые свойства.
В него вы должны поместить все свои группы (в нашем примере это red, blue и darkGray), и здесь же определить строковые значения, чтобы обеспечить корректную передачу в аналитику. Важная часть здесь — тип enum Group.
Group, позволяющее использовать весь потенциал Swift. Также у нас есть расширение ButtonColorSplitTest. Теперь давайте создадим объекты для 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), вероятно, вообще не играют и ничего не покупают в играх (что логично), и именно поэтому важно отправлять на сервер результат (сгенерированный вариант) сплит-тестирования в тот момент, когда пользователи действительно сталкиваются с вашим тестом.
С небольшим коэффициентом покупки совершали уже 3 %. Без коэффициента сложности только 2 % пользователей покупали игровую валюту. Это значит, что можно продолжить увеличивать коэффициент и наблюдать за цифрами. И с большим коэффициентом сложности 4 % игроков купили валюту. 🙂
Если вам интересно анализировать результаты с максимальной достоверностью, то советую использовать этот инструмент.
Спасибо замечательной команде, которая помогла мне в работе над этой статьёй (особенно Игорю, Келли и Хайро).
Весь демонстрационный проект доступен по этой ссылке.