Muy buenas, me llamo Miguel y hoy les traigo un nuevo artículo.
Índice
Una implementación eficiente
Como desarrolladores de iOS, a menudo necesitamos implementar el seguimiento en nuestras aplicaciones. Existen muchos frameworks de terceros que nos permitirían implementar sistemas de seguimiento en nuestros proyectos.
Pero en este artículo, vamos a hablar sobre cómo hemos implementado nuestra infraestructura de seguimiento personalizada en Freeletics
con la ayuda de CoreData
, sin utilizar ningún marco de terceros.
Nuestro sistema guardará cada evento generado por los usuarios, los almacenará temporalmente y una vez que la cantidad de eventos almacenados alcance el límite definido, todos los eventos se enviarán al servidor.
La infraestructura de seguimiento del lado del cliente se compone de tres entidades principales: almacenamiento, batcher y remitente.
-
TrackingEventStorage
: Responsable de almacenar, recuperar y eliminar eventos usandoCoreData
. -
TrackingEventsBatcher
: Responsable de agrupar eventos y actuar como una capa de comunicación entreTrackingEventStorage
yTrackingEventSender
. -
TrackingEventSender
: Responsable de enviar una lista de eventos al servidor.
Para el evento en sí, tenemos dos modelos diferentes. Uno es almacenarlos en CoreData
como NSManagedObject
(ManagedInHouseTrackingEvent
) y el otro es como una estructura simple (InHouseTrackingEvent
) para inicializar fácilmente desde el lado del consumidor y luego enviar al backend. Nuestros modelos tienen el siguiente aspecto:
@objc(ManagedInHouseTrackingEvent) public final class ManagedInHouseTrackingEvent: NSManagedObject { } extension ManagedInHouseTrackingEvent { @nonobjc public class func fetchRequest() -> NSFetchRequest<ManagedInHouseTrackingEvent> { return NSFetchRequest<ManagedInHouseTrackingEvent>(entityName: String(describing: ManagedInHouseTrackingEvent.self)) } @NSManaged public var name: String? @NSManaged public var properties: Data? @NSManaged public var id: String? } extension ManagedInHouseTrackingEvent { enum PropertyKey: String { case id case name case properties } }
struct InHouseTrackingEvent { let id: String let name: String let properties: [String: Any] }
Normalmente no necesitamos una propiedad de identificación para nuestros eventos, pero la usaremos más adelante mientras creamos modelos de eventos de datos centrales para que podamos distinguir los eventos persistentes entre sí más adelante.
Como puede ver, el campo de propiedades es de tipo Data
en nuestro modelo gestionado, mientras que es un [String: Any]
diccionario en InHouseTrackingEvent
.
Dado que solo vamos a utilizar modelos administrados para conservar los datos en lugar de manipular los existentes, solo vamos a convertir las propiedades a Data
para persistirlos fácilmente como Binary Data
con CoreData
.
Implementación de almacenamiento de eventos
Después de crear nuestros modelos, y también xcdatamodel
relacionado con ManagedInHouseTrackingEvent
, ahora continuaremos con la pila de datos principal.
Necesitamos tener una pila de datos central, donde nuestra clase de almacenamiento pueda inicializar el contexto administrado desde su contenedor persistente.
Luego usamos este contexto administrado para inicializar NSEntityDescription
que describirá nuestra entidad e interactuará con todas las operaciones CRUD para la base de datos.
final class InHouseTrackingCoreDataStack { static let shared = InHouseTrackingCoreDataStack() private let containerName = "FreeleticsInHouseTracking" private init() {} lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: containerName) container.loadPersistentStores(completionHandler: { [weak self] (_, error) in if let self = self, let error = error as NSError? { print("Error!") } }) return container }() }
Ahora podemos crear clase TrackingEventStorage
. Tendrá cuatro propiedades:
-
EntityName
: Representa el nombre de la clase para nuestro modelo de datos principal. -
CoreDataStack
: Una referencia a nuestra pila de datos principal. -
ManagedContext
: UnNSManagedObjectContext
que se utilizará para envolver todas las operaciones CRUD para datos básicos. -
EventEntity
: UnNSEntityDescription
que representa nuestro modelo de datos central.
final class TrackingEventsStorage { let managedContext: NSManagedObjectContext let eventEntity: NSEntityDescription? private let entityName = "ManagedInHouseTrackingEvent" private let coreDataStack = InHouseTrackingCoreDataStack.shared init() { managedContext = coreDataStack.persistentContainer.newBackgroundContext() eventEntity = NSEntityDescription.entity(forEntityName: entityName, in: managedContext) } }
Cuando inicializamos el managedContext
mediante el uso newBackgroundContext()
del contenedor persistente, tendrá el concurrencyType
de privateQueueConcurrencyType
.
Queremos tener un dedicado managedContext
de modo que siempre que se realice una operación de base de datos dentro, se asegurará de que todas las operaciones se ejecuten en la misma cola.
Además, ejecutaremos todo el código relacionado con los datos centrales dentro de un performAndWait [2] cierre de la managedContext
. Esto asegurará que todas nuestras operaciones se ejecuten sincrónicamente.
Necesitamos sincronicidad ya que muchas de nuestras acciones dependerán unas de otras, como asegurarnos de verificar los eventos almacenados después de almacenar un nuevo evento.
Vamos a implementar tres métodos públicos para que esta clase interactúe.
func storeEvent(_ event: InHouseTrackingEvent) func removeEvents(_ events: [InHouseTrackingEvent]) func storedEvents(withMaximumAmountOf limit: Int?) -> [InHouseTrackingEvent]?
Pero antes de eso, necesitamos implementar algunos métodos auxiliares privados de los que se beneficiarán los métodos públicos.
Primero, necesitaremos implementar un método para ejecutar una solicitud de recuperación determinada, que realizará la solicitud dada y devolverá sus resultados.
private func performFetchRequest(_ request: NSFetchRequest<NSFetchRequestResult>) -> [NSManagedObject]? { var objects: [NSManagedObject]? managedContext.performAndWait { do { objects = try managedContext.fetch(request) as? [NSManagedObject] } catch { print("Error!") } } return objects }
También necesitamos un método para crear una solicitud de recuperación para realizar, que tendrá dos parámetros:
-
Identifiers
: Una matriz opcional de identificadores para buscar. -
Limit
: Un entero opcional para establecer el límite de la solicitud de recuperación.
private func makeFetchRequest(withIDs identifiers: [String]? = nil, withMaximumAmountOf limit: Int? = nil) -> NSFetchRequest<NSFetchRequestResult> { let request = NSFetchRequest<NSFetchRequestResult>(entityName: entityName) if let identifiers = identifiers { request.predicate = NSPredicate(format: "id IN %@", identifiers) } if let limit = limit { request.fetchLimit = limit } return request }
A continuación, implementaremos el método coreDataObjects
que se recuperará almacenado NSManagedObjects
con dos parámetros:
-
Identifiers
: Una matriz opcional de identificadores para buscar. -
Limit
: Un número entero para establecer el límite de la solicitud de recuperación. y llamando tanto a los métodosmakeFetchRequest
yperformFetchRequest
.
private func coreDataObjects(withIDs identifiers: [String]? = nil, withMaximumAmountOf limit: Int? = nil) -> [NSManagedObject]? { let request = makeFetchRequest(withIDs: identifiers, withMaximumAmountOf: limit) return performFetchRequest(request) }
Otro componente que vamos a necesitar es un método para obtener eventos InHouseTrackingEvent
de eventos de objetos gestionados almacenados antes de proporcionarlos a las API de nivel superior.
Vamos a crear una clase de fábrica con el método makeEvent
de la siguiente manera:
final class InHouseTrackingEventFactory { typealias Keys = ManagedInHouseTrackingEvent.PropertyKey /// Initializes and returns an `InHouseTrackingEvent` from the given NSManagedObject /// - Returns: Returns an InHouseTrackingEvent from NSManagedObject or nil if any error occurs static func makeEvent(from object: NSManagedObject) -> InHouseTrackingEvent? { do { guard let propertiesData = object.value(forKey: Keys.properties.rawValue) as? Data, let properties = try JSONSerialization.jsonObject(with: propertiesData) as? [String: Any], let id = object.value(forKey: Keys.id.rawValue) as? String, let name = object.value(forKey: Keys.name.rawValue) as? String else { return nil } return InHouseTrackingEvent(id: id, name: name, properties: properties) } catch { print("Error!") } } }
Ahora podemos agregar un método en TrackingEventStorage
para convertir todos los eventos de objetos gestionados dados en InHouseTrackingEvent
:
private func events(from coreDataObjects: [NSManagedObject]) -> [InHouseTrackingEvent]? { var events = [InHouseTrackingEvent]() managedContext.performAndWait { for coreDataObject in coreDataObjects { if let event = InHouseTrackingEventFactory.makeEvent(from: coreDataObject) { events.append(event) } } } return events.isEmpty ? nil : events }
Finalmente, implementaremos un método saveContext
para asegurarnos de que los cambios que realizamos se mantendrán en la base de datos:
private func saveContext() { managedContext.performAndWait { do { guard managedContext.hasChanges else { return } try managedContext.save() } catch { print("Error!") } } }
Ahora estamos listos para implementar nuestros métodos públicos mencionados anteriormente. Estos métodos permitirán que otras entidades interactúen con nuestro mecanismo de seguimiento central.
Agreguemos un typealias
a TrackingEventStorage
clase que usaremos para nuestras claves de propiedad de modelos administrados:
typealias Keys = ManagedInHouseTrackingEvent.PropertyKey
El primer método público que vamos a implementar es storeEvent
, que persistirá dado InHouseTrackingEvent
como un NSManagedObject
.
func storeEvent(_ event: InHouseTrackingEvent) { guard let eventEntity = eventEntity else { return } managedContext.performAndWait { let managedEvent = NSManagedObject(entity: eventEntity, insertInto: managedContext) managedEvent.setValue(event.id, forKey: Keys.id.rawValue) managedEvent.setValue(event.name, forKey: Keys.name.rawValue) do { let propertyData = try JSONSerialization.data(withJSONObject: event.properties) managedEvent.setValue(propertyData, forKey: Keys.properties.rawValue) } catch { print("Error!") return } } saveContext() }
El segundo es removeEvents
que acepta una matriz de InHouseTrackingEvent
y elimina el modelo administrado correspondiente para cada evento en la matriz.
func removeEvents(_ events: [InHouseTrackingEvent]) { let eventIDs = events.map { $0.id } guard let coreDataObjects = coreDataObjects(withIDs: eventIDs) else { return } managedContext.performAndWait { coreDataObjects.forEach { self.managedContext.delete($0) } } saveContext() }
El último método público es storedEvents
que acepta el parámetro de límite para devolver los modelos gestionados almacenados con la cantidad máxima de límite.
func storedEvents(withMaximumAmountOf limit: Int?) -> [InHouseTrackingEvent]? { guard let objects = coreDataObjects(withMaximumAmountOf: limit) else { return nil } return events(from: objects) }
Implementación del remitente de eventos
Vamos a omitir los detalles de implementación para la clase de envío de eventos por simplicidad. InHouseTrackingEventSender
va a tener un método para enviar eventos que aceptará una matriz de InHouseTrackingEvent
y realiza una solicitud de URL para enviarlos al backend.
Además, tendrá una propiedad de delegado débil de tipo TrackingEventSenderDelegate
que será necesario para notificar una vez que los eventos se hayan enviado con éxito al backend.
Como probablemente haya notado, los errores no se manejan explícitamente. Si algo sale mal, simplemente no hacemos nada y enviamos los mismos eventos más adelante.
final class InHouseTrackingEventSender { weak var delegate: TrackingEventSenderDelegate? func sendEvents(_ events: [InHouseTrackingEvent]) { // Make sure there are no ongoing requests and make a // post request to the backend by including each event in // the body of the request. // success: delegate?.didSendEvents(events) // error: // Handle error } }
protocol TrackingEventSenderDelegate: class { func didSendEvents(_ events: [InHouseTrackingEvent]) }
Implementación de Event Batcher
Es hora de que implementemos la última parte de nuestro servicio de seguimiento. Necesitamos un mecanismo de procesamiento por lotes para asegurarnos de que nuestro sistema de seguimiento funcione teniendo en cuenta el rendimiento, la batería y el seguimiento en tiempo real.
Será un singleton y se usará para rastrear directamente un evento. Antes de implementar el singleton de batcher, escribamos una estructura simple que será responsable de proporcionar el tamaño del lote.
Podríamos codificar este valor, pero proporcionarlo a través de otra entidad puede hacer que sea más fácil y claro mantener esta información, especialmente si se puede actualizar mediante configuraciones remotas.
struct TrackingEventsBatchSizeProvider { let defaultBatchSize = 20 }extension TrackingEventsBatchSizeProvider: TrackingEventsBatchSizeProviding { var batchSize: Int { // We just return default size for simplicity but we could get some remote config value // at this point and provide it as well. return defaultBatchSize } }
Ahora podemos crear el singleton batcher, TrackingEventsBatcher
.
Se inicializará con cuatro propiedades:
-
ShouldBatchEvents
: Un valor booleano para indicar si los eventos deben enviarse por lotes o enviarse inmediatamente. -
EventStorage
: Una instancia deTrackingEventStorage
. -
EventSender
: Una instancia deInHouseTrackingEventSender
. -
BatchSizeProvider
: Una estructura para proporcionar qué tan grande debe ser el tamaño del lote.
También se ajustará a TrackingEventSenderDelegate
para establecerse como el delegado de la clase de remitente de eventos inicializada.
final class TrackingEventsBatcher: TrackingEventSenderDelegate { static let shared = TrackingEventsBatcher() var shouldBatchEvents = true private var eventStorage: TrackingEventStoring private var eventSender: TrackingEventSending private var batchSizeProvider: TrackingEventsBatchSizeProviding init(eventStorage: TrackingEventStoring = TrackingEventsStorage(), eventSender: TrackingEventSending = InHouseTrackingEventSender(), batchSizeProvider: TrackingEventsBatchSizeProviding = TrackingEventsBatchSizeProvider()) { self.eventStorage = eventStorage self.eventSender = eventSender self.batchSizeProvider = batchSizeProvider self.eventSender.delegate = self } func didSendEvents(_ events: [InHouseTrackingEvent]) { // empty for now } }
Como puedes ver, shouldBatchEvents
es una propiedad pública para que luego pueda ser modificada.
El control con esta bandera nos permitirá enviar eventos rastreados inmediatamente o agruparlos hasta que alcancemos el tamaño del lote. Por la sencillez de este artículo, siempre será cierto.
Ahora agregaremos 2
métodos privados auxiliares, el primero es para determinar si los eventos deben enviarse y otro para enviar eventos si es necesario:
private func shouldSendEvents(_ events: [InHouseTrackingEvent]) -> Bool { // Send events if they shouldn't be batched, regardless of their number // or only if their number is greater than the batch size, if they should be batched. return !shouldBatchEvents || events.count >= batchSizeProvider.batchSize }private func sendEventsIfNeeded() { guard let storedEvents = eventStorage.storedEvents(withMaximumAmountOf: batchSizeProvider.batchSize), shouldSendEvents(storedEvents) else { return } eventSender.sendEvents(storedEvents) }
Primero obtenemos eventos almacenados con batchSize limit
y luego ver si ya deberíamos enviar eventos.
Ahora implementaremos el método que será el punto de entrada de toda nuestra infraestructura de rastreo, el siguiente método será llamado en toda la aplicación donde una entidad necesita rastrear un evento.
func batchEvent(_ event: InHouseTrackingEvent) { eventStorage.storeEvent(event) sendEventsIfNeeded() }
Siempre que se rastrea un evento batchEvent
, lo almacenaremos y comprobaremos si se deben enviar los eventos.
Finalmente, actualizaremos el método didSendEvents
de la siguiente manera:
func didSendEvents(_ events: [InHouseTrackingEvent]) { eventStorage.removeEvents(events) sendEventsIfNeeded() }
Nos aseguramos de que todos los eventos enviados se eliminen del almacenamiento y verificamos si se deben enviar más eventos.
Esta lógica es necesaria porque la cantidad de eventos almacenados podría haber sido más del doble del tamaño del lote.
Esto puede ocurrir cuando la aplicación se usa sin conexión y no se han enviado eventos durante un tiempo.
Uso
Veamos cómo podemos interactuar con el sistema con una clase de muestra:
class SampleEntity { func trackSomething() { let eventName = "example_event" let id = "\(eventName)_\(Date().timeIntervalSince1970)" let properties: [String: Any] = [ "propertyOne": "1", "propertyTwo": true ] let event = InHouseTrackingEvent(id: id, name: eventName, properties: properties) TrackingEventsBatcher.shared.batchEvent(event) } }
Por lo general, tenemos diferentes entidades para diferentes eventos en nuestras aplicaciones y esta conversión manual de propiedades puede evitarse proporcionando un mecanismo para convertir las propiedades al formato de diccionario requerido a través de entidades de eventos.
Pero para simplificar, solo agregamos dos propiedades aleatorias y mostramos cómo se puede agrupar aquí. También podríamos implementar una función contenedora llamada track
, que también podría manejar internamente el procesamiento por lotes.
Más mejoras para TrackingEventStorage
TrackingEventStorage
. Especialmente para el método saveContext()
. Hay una propiedad llamada isProtectedDataAvailable
que vive dentro UIApplication
.
Esta propiedad nos ayudará a determinar si hay protección de datos activa o si el dispositivo está bloqueado. En tales casos, no deberíamos intentar realizar operaciones de base de datos, de lo contrario, podríamos experimentar algunos bloqueos. [3].
Agreguemos la verificación para esta propiedad mientras verificamos si también hay cambios (en saveContext
):
guard UIApplication. compartido . isProtectedDataAvailable, managedContext . hasChanges else { return }
Se podría esperar que esto funcione de inmediato, pero ahora tenemos otro problema. Hemos implementado nuestro mecanismo de seguimiento como seguro para subprocesos, pero solo deberíamos verificar UIApplication.shared.isProtectedDataAvailable
de la cola principal.
Podríamos hacer comprobar if Thread.isMainThread
, pero vamos a optar por una solución diferente, ya que esta comprobación podría no ser suficiente y segura para asegurarnos de que podemos sincronizar con la cola principal [4].
Vamos a utilizar una versión refactorizada de esta publicación para determinar en qué cola de despacho nos estamos ejecutando correctamente.
import Foundation// Reference https://stackoverflow.com/a/60314121/8447312 public extension DispatchQueue static var current: DispatchQueue? getSpecific(key: key)?.queue private struct QueueReference weak var queue: DispatchQueue? private static let key: DispatchSpecificKey<QueueReference> = let key = DispatchSpecificKey<QueueReference>() setUpSystemQueuesDetection(key: key) return key () private static func setUpSystemQueuesDetection(key: DispatchSpecificKey<QueueReference>) let queues: [DispatchQueue] = [ .main, .global(qos: .background), .global(qos: .default), .global(qos: .unspecified), .global(qos: .userInitiated), .global(qos: .userInteractive), .global(qos: .utility) ] registerDetection(of: queues, key: key) private static func registerDetection(of queues: [DispatchQueue], key: DispatchSpecificKey<QueueReference>) queues.forEach $0.setSpecific(key: key, value: QueueReference(queue: $0)) } } }
Ahora agregaremos un nuevo método a TrackingEventsStorage
para comprobar si isProtectedDataAvailable
correctamente:
private func isProtectedDataAvailable() -> Bool { var isProtectedDataAvailable = false if DispatchQueue.current == DispatchQueue.main { isProtectedDataAvailable = UIApplication.shared.isProtectedDataAvailable } else { DispatchQueue.main.sync { isProtectedDataAvailable = UIApplication.shared.isProtectedDataAvailable } } return isProtectedDataAvailable }
Cambiemos el método saveContext
al siguiente, con el fin de hacer uso UIApplication.shared.isProtectedDataAvailable
es cierto antes de guardar el contexto.
private func saveContext() { let protectedDataAvailable = isProtectedDataAvailable() managedContext.performAndWait { do { guard protectedDataAvailable, managedContext.hasChanges else { return } try managedContext.save() } catch { print("Error!") } } }
Una cosa a tener en cuenta es que estamos haciendo cambios de cola, si es necesario, fuera del cierre performAndWait
.
Es necesario desde perform
y performAndWait
los cierres solo deben usarse para cambios relacionados con NSManagedObjects
.
En este artículo, hemos visto cómo CoreData
se puede utilizar para la implementación de un sistema de seguimiento de eventos personalizado.
Hemos construido un sistema para conservar eventos temporalmente en el dispositivo y enviarlos al backend
en lotes.
También nos hemos asegurado de que se pueda acceder a dicho sistema desde diferentes subprocesos / colas y exploramos formas de determinar correctamente la cola actual de ejecución.
Gracias por leer este artículo.
Añadir comentario