company_banner

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


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

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

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

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

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

    Краткое введение в сплит-тестирование


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

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

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

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

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



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



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

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

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

    Сплит-тестирование и Swift


    Цели:

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

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

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

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

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

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

    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 % игроков купили валюту. Это значит, что можно продолжить увеличивать коэффициент и наблюдать за цифрами. :)

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

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

    Весь демонстрационный проект доступен по этой ссылке.
    • +47
    • 7,3k
    • 1

    Badoo

    305,00

    Big Dating

    Поделиться публикацией
    Комментарии 1
      –4
      Можете еще внедрить зонд и сливать на продажу приватную информацию.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое