In the previous article we created very simple SyncService service that can send and receive data between iOS as watch app. Which is good for very simple scenarios, but when application complexity is growing it is really important to keep you code clean, and well organized. In this article we will try to isolate sync logic from the rest of the application.

In one of my project I had to synchronize data based on different events, in different part of application. But all that code had a lot in common. So, to clean up it a bit and make it more reusable I introduced generic SyncObject class that encapsulates all basic sync logic.

Create Sync Object

Lets get started with creating ISyncObject protocol (yeah, I’m a C# developer 😄).

protocol ISyncObject {
    var key: Notification.Name { get }
    var publisher: NotificationCenter.Publisher { get }
    
    func sync()
    func sync<T: Codable>(_ message: T)
    func received(_ message: Any)
}

Where:

  • key: this property is a unique key that is used to identifying sync object.
  • publisher: the property emits received message, mostly will be used to update UI.
  • sync: methods that is used to send data to the counterpart app.
  • received: receives data from counterpart app.

Initialize

Now, when protocol is defined lets create base generic class that encapsulates all basic logic. All DTO object will have Codable constraint that allows to serialize and deserialize them.

class SyncObject<T: Codable>: ISyncObject { 
    internal let service: SyncService
    private (set) var key: Notification.Name
    private (set) var publisher: NotificationCenter.Publisher
    
    init(_ key: Notification.Name, service: SyncService) {
        self.key = key
        self.service = service
        self.publisher = NotificationCenter.default.publisher(for: key)
    }
}

The initializer accepts unique key (it can be any, it just need to be unique), SyncService service and creates NotificationCenter publisher for specified unique key.

Add properties

Lets add several useful properties, that can be used to track synchronization. These properties will be stored in UserDefaults to survive app restart.

  • lastSyncTime defines when object was last time synchronized.

    var lastSyncTime: Date? {
        get { return UserDefaults.standard.value(forKey: "syncApp.\(key.rawValue).lastSyncTime") as? Date }
        set {
            UserDefaults.standard.set(newValue, forKey: "syncApp.\(key.rawValue).lastSyncTime")
            needSync = false
        }
    }
    
  • needSync defines if object need to be synchronized. Lets say you tried to sync, but it failed by any reason and you what to retry later or you maybe want to check it if was already synced, etc.

    var needSync: Bool {
        get { return UserDefaults.standard.value(forKey: "syncApp.\(key.rawValue).needSync") as? Bool ?? true }
        set { UserDefaults.standard.set(newValue, forKey: "syncApp.\(key.rawValue).needSync") }
    }
    

Implement synchronization logic

Now, lets implement sync methods to send data.

  • The parameterless method overload we leave empty. Mostly it should be overridden by child object to perform custom sync logic if needed.
  • The parameterized method overload is very straightforward it checks if session is active and if yes then serializes data to JSON format and sends it.
func sync() { }

func sync<T: Codable>(_ message: T) {
    if !service.isReachable {
        needSync = true
        return
    }
    
    let data = JsonHelper.toJson(message)
    service.sendMessage(key.rawValue, data, { error in
        self.needSync = true
    })
    
    lastSyncTime = Date()

    print("[Sync:\(key.rawValue)] Sent data: \(data)")
}

Implement receiving logic

And one more thing, we need to implement received method to receive data.

  • The first generic methods overload simply deserialize data and send to second overload.
  • The second overload uses NotificationCenter to post received message. All publisher’s subscribers will receive posted message. Also, mostly should be overridden by child object to perform custom processing logic if needed.
func received(_ message: Any) {
    print("[Sync:\(key.rawValue)] Received data: \(message)")

    let data: T? = JsonHelper.fromJson(message as? String)
    if let data = data {
        received(data)
    }
}

internal func received(_ message: T) {
    publish(message)
}

internal func publish(_ message: T) {
    NotificationCenter.default.post(name: key, object: message)
}

Update Sync Service

Before we can use our SyncObject we need update SyncService a bit.

Add missed properties

First of all we need to add isReachable property that we used in the SyncObject object.

var isReachable: Bool {
    return session.isReachable
}

Create registry

Next, we need to create sync object registry, that will track all our sync objects. For simplicity, it can be a simple collection. In the initializer we need to register all ISyncObject we have. And also we need a way to get ISyncObject object by key from the registry.

private var syncObjects: [ISyncObject] = []

init(session: WCSession = .default) {
    self.session = session

    super.init()
    
    self.syncObjects = [
        // register all sync objects here
    ]
    
    self.session.delegate = self
    self.connect()
}

func getSyncObject(_ key: Notification.Name) -> ISyncObject {
    return syncObjects.first { it in it.key == key }!
}

Update receiving logic

The last thing we need to do is to update receive method. That simply gets ISyncObject object from registry based on message key and then uses that object to process the message.

func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
    DispatchQueue.main.async {
        for msg in message {
            self.getSyncObject(Notification.Name(rawValue: msg.key)).received(msg.value)
        }
    }
}

Usage

The usage is very simple. First of all lets create DTO object we want to sync.

struct SomeData: Codable  {
    var data: String
}

Next create SomeDataSyncObject object that will encapsulate all business logic and register it in the registry.

class SomeDataSyncObject: SyncObject<SomeData> {
    static let Key = Notification.Name("someData")
    
    init(_ service: SyncService) {
        super.init(SomeDataSyncObject.Key, service: service)
    }
}

class SyncService: NSObject, WCSessionDelegate {
    init(session: WCSession = .default) {
        //...
        super.init()
        self.syncObjects = [
            SomeDataSyncObject(self)
        ]
        //...
    }
    //...
}

And lets use it. To test this service you need to start iOS and watch simulators all together.

Application that sends data

var syncObject = SyncService().getSyncObject(SomeDataSyncObject.Key)
syncObject.sync(SomeData(data: "Some data to sync"))

Application that receives data

var syncObject = SyncService().getSyncObject(SomeDataSyncObject.Key)

// somewhere on UI
VStack {
  
}
.onReceive(syncObject.publisher) { data in
    if let data = data.object as? SomeData {
       // Update UI here
    }
}

Also see Source Code section for more practical usage.

Summary

Lets combine all together to get full picture.

SyncObject

import Foundation

protocol ISyncObject {
    var key: Notification.Name { get }
    var publisher: NotificationCenter.Publisher { get }
    
    func sync()
    func sync<T: Codable>(_ message: T)
    func received(_ message: Any)
}

class SyncObject<T: Codable>: ISyncObject {
    let service: SyncService
    private (set) var key: Notification.Name
    private (set) var publisher: NotificationCenter.Publisher
    
    var lastSyncTime: Date? {
        get { return UserDefaults.standard.value(forKey: "syncApp.\(key.rawValue).lastSyncTime") as? Date }
        set {
            UserDefaults.standard.set(newValue, forKey: "syncApp.\(key.rawValue).lastSyncTime")
            needSync = false
        }
    }
    
    var needSync: Bool {
        get { return UserDefaults.standard.value(forKey: "syncApp.\(key.rawValue).needSync") as? Bool ?? true }
        set { UserDefaults.standard.set(newValue, forKey: "syncApp.\(key.rawValue).needSync") }
    }

    init(_ key: Notification.Name, service: SyncService) {
        self.key = key
        self.service = service
        self.publisher = NotificationCenter.default.publisher(for: key)
    }
     
    func sync() { }
    
    func sync<T: Codable>(_ message: T) {
        if !service.isReachable {
            needSync = true
            return
        }
        
        let data = JsonHelper.toJson(message)
        service.sendMessage(key.rawValue, data, { error in
            self.needSync = true
        })
        
        lastSyncTime = Date()
    
        print("[Sync:\(key.rawValue)] Sent data: \(data)")
    }
    
    func received(_ message: Any) {
        print("[Sync:\(key.rawValue)] Received data: \(message)")

        let data: T? = JsonHelper.fromJson(message as? String)
        if let data = data {
            received(data)
        }
    }
    
    internal func received(_ message: T) {
        publish(message)
    }
    
    internal func publish(_ message: T) {
        NotificationCenter.default.post(name: key, object: message)
    }
}

SyncService

import Foundation
import WatchConnectivity
import Combine

class SyncService : NSObject, WCSessionDelegate {
    private var syncObjects: [ISyncObject] = []
    private var session: WCSession = .default
    
    init(session: WCSession = .default) {
        self.session = session

        super.init()
        
        self.syncObjects = [
            TestSyncDataSyncObject(self)
        ]
        
        self.session.delegate = self
        self.connect()
    }
    
    func getSyncObject(_ key: Notification.Name) -> ISyncObject {
        return syncObjects.first { it in it.key == key }!
    }
    
    var isReachable: Bool {
        return session.isReachable
    }
    
    func connect(){
        guard WCSession.isSupported() else {
            print("WCSession is not supported")
            return
        }
        
        session.activate()
    }

    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { }

    #if os(iOS)
    func sessionDidBecomeInactive(_ session: WCSession) { }

    func sessionDidDeactivate(_ session: WCSession) { }
    #endif

    func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
        DispatchQueue.main.async {
            for msg in message {
                self.getSyncObject(Notification.Name(rawValue: msg.key)).received(msg.value)
            }
        }
    }

    func sendMessage(_ key: String, _ message: String, _ errorHandler: ((Error) -> Void)?) {
        if session.isReachable {
            session.sendMessage([key : message], replyHandler: nil) { (error) in
                print(error.localizedDescription)
                if let errorHandler = errorHandler {
                    errorHandler(error)
                }
            }
        }
    }
}

Source Code

The sample application for this article you can find here .