Your browser is not supported anymore. Please update to a more recent one.


Download Chrome

Download Firefox

Download
Internet Explorer

Download Safari

Как проверить гипотезы и заработать на 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, взятую из
https://stackoverflow.com/questions/25050309/swift-random-float-between-0-and-1, но в 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, и, если вы не удалите приложение с устройства, кнопка будет одинаковой при каждом запуске.
<hr/>
Процесс реализации такой библиотеки занимает некоторое время, но после этого каждый новый сплит-тест внутри вашего проекта будет создаваться за пару минут.

Вот пример использования движка в реальном приложении: в аналитике мы сегментировали пользователей по коэффициенту сложности и вероятности покупки игровой валюты.



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

Без коэффициента сложности только 2 % пользователей покупали игровую валюту. С небольшим коэффициентом покупки совершали уже 3 %. И с большим коэффициентом сложности 4 % игроков купили валюту. Это значит, что можно продолжить увеличивать коэффициент и наблюдать за цифрами. :)

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

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

Весь демонстрационный проект доступен по этой ссылке: https://github.com/azimin/SplitTestingArticle.