Hola a todos! Mi nombre es Sasha Zimin, trabajo como desarrollador de iOS en la oficina de
Badoo en Londres. Badoo tiene una relación muy estrecha con los gerentes de producto, y asumí el hábito de probar todas las hipótesis que tengo con respecto al producto. Entonces, comencé a escribir pruebas divididas para mis proyectos.
El marco que se discutirá en este artículo fue escrito para dos propósitos. En primer lugar, para evitar posibles errores, es mejor no tener datos en el sistema de análisis que datos incorrectos (o incluso datos que pueden interpretarse incorrectamente y dividirse en leña). En segundo lugar, para simplificar la implementación de cada prueba posterior. Pero comencemos con lo que son las pruebas divididas.
Hoy en día, hay millones de aplicaciones que resuelven la mayoría de las necesidades de los usuarios, por lo que cada día se hace más difícil crear nuevos productos competitivos. Esto ha llevado a muchas empresas y nuevas empresas a realizar diversas investigaciones y experimentos al principio para descubrir qué características mejoran su producto y cuáles se pueden prescindir.
Una de las principales herramientas para llevar a cabo tales experimentos es la prueba dividida (o prueba A / B). En este artículo contaré cómo se puede implementar en Swift.
Todas las demostraciones de proyectos están disponibles
aquí . Si ya tiene una idea sobre las pruebas A / B, puede ir directamente
al código .
Una breve introducción a las pruebas divididas
Las pruebas divididas o pruebas A / B (este término no siempre es correcto, porque puede tener más de dos grupos de participantes), es una forma de verificar diferentes versiones de un producto en diferentes grupos de usuarios para comprender qué versión es mejor. Puede leer sobre esto en
Wikipedia o, por ejemplo, en
este artículo con ejemplos reales.
En Badoo, ejecutamos muchas pruebas divididas al mismo tiempo. Por ejemplo, una vez que decidimos que la página de perfil de usuario en nuestra aplicación parece obsoleta, y también queríamos mejorar la interacción del usuario con algunos banners. Por lo tanto, lanzamos pruebas divididas con tres grupos:
- Perfil antiguo
- Nueva versión de perfil 1
- Nueva versión de perfil 2
Como puede ver, teníamos tres opciones, más como pruebas A / B / C (y es por eso que preferimos usar el término "prueba dividida").
Entonces, diferentes usuarios vieron sus perfiles:
En la consola de Product Manager, teníamos cuatro grupos de usuarios formados al azar y con el mismo número:
¿Quizás se pregunte por qué tenemos control y control_check (si control_check es una copia de la lógica del grupo de control)? La respuesta es muy simple: cualquier cambio afecta a muchos indicadores, por lo que nunca podemos estar absolutamente seguros de que un cambio en particular sea el resultado de una prueba dividida, y no otras acciones.
Si cree que algunos indicadores han cambiado debido a la prueba de división, debe verificar que sean los mismos dentro de los grupos control y control_check.
Como puede ver, las opiniones de los usuarios pueden diferir, pero la evidencia empírica es una evidencia clara. El equipo de gerentes de producto analiza los resultados y comprende por qué una opción es mejor que otra.
Prueba dividida y rápida
Objetivos:
- Cree una biblioteca para el lado del cliente (sin usar un servidor).
- Guarde la opción de usuario seleccionada en el almacenamiento permanente después de que se generó accidentalmente.
- Envíe informes sobre las opciones seleccionadas para cada prueba dividida al servicio de análisis.
- Aproveche al máximo las capacidades de Swift.
PD: El uso de dicha biblioteca para pruebas divididas de la parte del cliente tiene sus ventajas y desventajas. La principal ventaja es que no necesita tener una infraestructura de servidor o un servidor dedicado. Y la desventaja es que si algo sale mal durante el experimento, no puede retroceder sin descargar la nueva versión en la App Store.
Algunas palabras sobre la implementación:
- Durante el experimento, la opción para el usuario se selecciona aleatoriamente de acuerdo con el principio igualmente probable.
- El servicio de prueba dividida puede usar:
- Cualquier almacén de datos (por ejemplo, UserDefaults, Realm, SQLite o Core Data) como una dependencia y guardar el valor asignado al usuario (el valor de su variante) en él.
- Cualquier servicio de análisis (por ejemplo, Amplitud o Facebook Analytics) como una dependencia y envía la versión actual en el momento en que el usuario encuentra una prueba dividida.
Aquí hay un diagrama de futuras clases:
Todas las pruebas divididas se presentarán utilizando
SplitTestProtocol , y cada una de ellas tendrá varias opciones (grupos) que se presentarán en
SplitTestGroupProtocol .
La prueba de división debería poder informar al analista sobre la versión actual, por lo tanto, tendrá
AnalyticsProtocol como dependencia.
El servicio
SplitTestingService guardará, generará opciones y administrará todas las pruebas divididas. Es él quien descarga la versión actual del usuario del almacenamiento, que está determinado por
StorageProtocol , y también pasa el
AnalyticsProtocol a
SplitTestProtocol .
Comencemos a escribir código con las
dependencias AnalyticsProtocol y
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? }
El papel de la analítica es registrar un evento una vez. Por ejemplo, para arreglar que el usuario
A esté en el grupo
azul durante la prueba de división
button_color , cuando ve una pantalla con este botón.
La función del repositorio es guardar una opción específica para el usuario actual (después de que
SplitTestingService generó esta opción) y luego volver a leerla cada vez que el programa accede a esta prueba dividida.
Entonces, echemos un vistazo a
SplitTestGroupProtocol , que caracteriza un conjunto de opciones para una prueba de división específica:
protocol SplitTestGroupProtocol: RawRepresentable where RawValue == String { static var testGroups: [Self] { get } }
Dado que
RawRepresentable donde RawValue es una cadena, puede crear fácilmente una variante a partir de una cadena o convertirla nuevamente en una cadena, lo cual es muy conveniente para trabajar con análisis y almacenamiento.
SplitTestGroupProtocol también contiene una matriz de testGroups, que puede indicar la composición de las opciones actuales (esta matriz también se utilizará para la generación aleatoria de las opciones disponibles).
Este es el prototipo base para la prueba de división
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 contiene:
- Un tipo GroupType que implementa el protocolo SplitTestGroupProtocol para representar un tipo que define un conjunto de opciones.
- El identificador de valor de cadena para las claves de análisis y almacenamiento.
- La variable currentGroup para registrar una instancia específica de SplitTestProtocol .
- Dependencia de análisis para el método hitSplitTest .
- Y el método hitSplitTest , que informa al analista que el usuario vio el resultado de la prueba de división.
El método hitSplitTest le permite asegurarse de que los usuarios no solo estén en una versión en particular, sino que también vean el resultado de la prueba. Si marca a un usuario que no ha visitado la sección de compras como "saw_red_button_on_purcahse_screen", esto distorsionará los resultados.
Ahora estamos listos para
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] } }
PD En esta clase usamos la función Int.random, tomada de
aquí , pero en Swift 4.2 ya está integrado por defecto.
Esta clase contiene un método público
fetchSplitTest y tres métodos privados:
saveGroup ,
getGroup ,
randomGroup .
El método randomGroup genera una variante aleatoria para la prueba de división seleccionada, mientras que getGroup y saveGroup le permiten guardar o cargar la variante para una prueba de división específica para el usuario actual.
La función principal y pública de esta clase es fetchSplitTest: intenta devolver la versión actual del almacenamiento persistente y, si no tiene éxito, genera y guarda una versión aleatoria antes de devolverla.
Ahora estamos listos para crear nuestra primera prueba dividida:
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 } } }
Parece impresionante, pero no se preocupe: tan pronto como implemente SplitTestProtocol como una clase separada, el compilador le pedirá que implemente todas las propiedades necesarias.
La parte importante aquí es el tipo de
grupo enum . Debe poner todos sus grupos en él (en nuestro ejemplo, rojo, azul y darkGray) y definir valores de cadena aquí para garantizar la transferencia correcta a la analítica.
También tenemos una extensión
ButtonColorSplitTest.Group , que le permite utilizar todo el potencial de Swift. Ahora creemos los objetos para
AnalyticsProtocol y
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 } }
Para
StorageProtocol usaremos la clase UserDefaults porque es fácil de implementar, pero en sus proyectos puede trabajar con cualquier otro almacenamiento persistente (por ejemplo, elegí Keychain para mí, ya que guarda el grupo para el usuario incluso después de la eliminación).
En este ejemplo, crearé una clase de análisis ficticio, pero puede utilizar análisis reales en su proyecto. Por ejemplo, puede usar el servicio
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) } }
Ahora estamos listos para usar nuestra prueba dividida:
let splitTestingService = SplitTestingService(analyticsService: Analytics(), storage: UserDefaults.standard) let buttonSplitTest = splitTestingService.fetchSplitTest(ButtonColorSplitTest.self) self.button.backgroundColor = buttonSplitTest.currentGroup.color buttonSplitTest.hitSplitTest()
Simplemente cree su propia instancia, extraiga la prueba de división y úsela. Las generalizaciones le permiten llamar a
buttonSplitTest.currentGroup.color.
Durante el primer uso, puede ver algo como (
Valor de registro único )
: split_test-button_color para clave: dark_gray , y si no elimina la aplicación del dispositivo, el botón será el mismo cada vez que inicie.
El proceso de implementación de una biblioteca de este tipo lleva algún tiempo, pero después de eso, cada nueva prueba dividida dentro de su proyecto se creará en un par de minutos.
Aquí hay un ejemplo del uso del motor en una aplicación real: en análisis, segmentamos a los usuarios por el coeficiente de complejidad y la probabilidad de comprar moneda del juego.

Las personas que nunca se han encontrado con este factor de dificultad (ninguno) probablemente no jueguen en absoluto y no compren nada en los juegos (lo cual es lógico), por eso es importante enviar el resultado (versión generada) de la prueba dividida al servidor en un momento en que Los usuarios realmente encuentran su prueba.
Sin un factor de dificultad, solo el 2% de los usuarios compraron moneda del juego. Con una pequeña proporción, las compras ya se realizaron 3%. Y con un factor de dificultad alto, el 4% de los jugadores compraron la moneda. Esto significa que puede continuar aumentando el coeficiente y observar los números. :)
Si está interesado en analizar los resultados con la máxima fiabilidad, le aconsejo que utilice
esta herramienta .
Gracias al maravilloso equipo que me ayudó a trabajar en este artículo (especialmente
Igor ,
Kelly e
Hiro ).
El proyecto de demostración completo está disponible en
este enlace .