Swift. Two-way communication between an iOS app and watchOS app. PART 2
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 .