Хабрахабр

Как проверить гипотезы и заработать на Swift с помощью сплит-тестов

Всем привет! Меня зовут Саша Зимин, я работаю iOS-разработчиком в лондонском офисе Badoo. В Badoo очень тесное взаимодействие с продуктовыми менеджерами, и я перенял у них привычку проверять все гипотезы, которые возникают у меня относительно продукта. Так, я начал писать сплит-тесты для своих проектов.

Во-первых, чтобы избежать возможных ошибок, ведь лучше отсутствие данных в системе аналитики, чем данные некорректные (или вообще данные, которые можно неверно интерпретировать и наломать дров). Фреймворк, о котором пойдет речь в этой статье, был написан с двумя целями. Но начнём, пожалуй, с того, что представляют из себя сплит-тесты.
В наше время существуют миллионы приложений, решающих большинство потребностей пользователей, поэтому с каждым днём становится всё сложнее создавать новые конкурентоспособные продукты. Во-вторых, чтобы упростить внедрение каждого последующего теста. Это привело к тому, что многие компании и стартапы сначала проводят различные исследования и эксперименты, чтобы выяснить, какие функции делают их продукт лучше, а без каких можно обойтись.

В этой статье я расскажу, как его можно реализовать на Swift. Одним из основных инструментов для проведения таких экспериментов является сплит-тестирование (или A/B-тестирование).

Если вы уже имеете представление об A/B-тестировании, то можете сразу переходить к коду. Все демонстрационные материалы проекта доступны по ссылке.

Сплит-тестирование, или A/B-тестирование (этот термин не всегда корректен, ведь у вас может быть и более двух групп участников), является способом проверки различных версий продукта на разных группах пользователей с целью понять, какая версия лучше. Почитать об этом можно в «Википедии» или, например, в этой статье с реальными примерами.

Например, однажды мы решили, что страница профиля пользователя в нашем приложении выглядит устаревшей, а также захотели улучшить взаимодействие пользователей с некоторыми баннерами. В Badoo мы проводим одновременно много сплит-тестов. Поэтому мы запустили сплит-тестирование с тремя группами:

  1. Старый профиль
  2. Новый профиль, версия 1
  3. Новый профиль, версия 2

Как видите, у нас было три варианта, больше похоже на A/B/C-тестирование (и именно поэтому мы предпочитаем использовать термин «сплит-тестирование»).

Так разные пользователи видели свои профили:

В консоли Product Manager у нас было четыре группы пользователей, сформированных случайным образом и имеющих одинаковую численность:

Ответ очень прост: любое изменение влияет на множество показателей, поэтому мы никогда не можем быть абсолютно уверены в том, что то или иное изменение является результатом проведения сплит-теста, а не других действий. Возможно, вы спросите, почему у нас есть control и control_check (если control_check — это копия логики группы control)?

Если вы считаете, что какие-то показатели изменились из-за сплит-теста, то следует дважды проверить, что внутри групп control и control_check они одинаковы.

Команда продуктовых менеджеров анализирует результаты и понимает, почему один вариант лучше другого.
Как видите, мнения пользователей могут отличаться, но эмпирические данные являются наглядным доказательством.

Цели:

  1. Создать библиотеку для клиентской части (без использования сервера).
  2. Сохранять выбранный вариант юзера в постоянном хранилище после того, как он был случайно сгенерирован.
  3. Отправлять отчёты о выбранных вариантах для каждого сплит-теста в сервис аналитики.
  4. Как можно шире использовать возможности Swift.

P. S. Использование такой библиотеки для сплит-тестирования клиентской части имеет свои преимущества и недостатки. Главное преимущество заключается в том, что вам не нужно иметь серверную инфраструктуру или выделенный сервер. А недостаток — в том, что, если в ходе эксперимента что-то пойдёт не так, вы не сможете откатиться назад без загрузки новой версии в App Store.

Несколько слов о реализации:

  1. При проведении эксперимента вариант для пользователя выбирается случайным образом по равновероятному принципу.
  2. Сервис сплит-тестирования может использовать:

  • Любое хранилище данных (например, 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 содержатся:

  1. Тип GroupType, который реализует протокол SplitTestGroupProtocol для представления типа, определяющего набор вариантов.
  2. Строковое значение identifier для аналитики и ключей хранилища.
  3. Переменная currentGroup для записи конкретного экземпляра SplitTestProtocol.
  4. Зависимость analytics для метода hitSplitTest.
  5. И метод 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 % игроков купили валюту. 🙂

Если вам интересно анализировать результаты с максимальной достоверностью, то советую использовать этот инструмент.

Спасибо замечательной команде, которая помогла мне в работе над этой статьёй (особенно Игорю, Келли и Хайро).

Весь демонстрационный проект доступен по этой ссылке.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»