Front-End Web & Mobile
Using Swift Combine with AWS Amplify
This article was written by Kyle Lee, Senior Developer Advocate, AWS Amplify
While there may be a lot of great things that are included in the AWS Amplify 1.1 release for iOS, one of the most exciting is support for Combine. Combine is a first party reactive framework that makes it easy to deal with asynchronous events in a declarative way.
Using the libraries is very straight forward already since almost all the API work with the Swift.Result type, but now code can be even cleaner AND reactive all while avoiding callback hell.
One of the most common use cases developers come across when programming an app that performs networking requests is performing one or more tasks, then taking the data from those tasks to perform another task.
Here’s what it might look like if you wanted to identify objects in an image and upload the image asynchronously, then create a post from the image with callbacks:
func savePostWithCallbacks() {
let imageKey = UUID().uuidString + ".jpg"
// Label objects in image
dispatchGroup.enter()
_ = Amplify.Predictions.identify(type: .detectLabels(.labels), image: imageUrl) { result in
switch result {
case .success(let identifyResult):
let labelsResult = identifyResult as! IdentifyLabelsResult
self.labels = labelsResult.labels.map(\.name)
dispatchGroup.leave()
case .failure(let error):
print(error)
}
}
// Upload image to storage
dispatchGroup.enter()
_ = Amplify.Storage.uploadFile(key: imageKey, local: imageUrl) { result in
switch result {
case .success:
dispatchGroup.leave()
case .failure(let error):
print(error)
}
}
// Only save the post once image has been uploaded and object in
// the image have been identified
dispatchGroup.notify(queue: .global()) {
let post = Post(imageKey: imageKey, tags: self.labels)
_ = Amplify.API.mutate(request: .create(post)) { event in
switch event {
case .success(let result):
switch result {
case .success(let post):
print("Post saved - \(post)")
case .failure(let error):
print(error)
}
case .failure(let error):
print("Event error - \(error)")
}
}
}
}
And here’s what that same process looks like using Combine:
@State var token: AnyCancellable?
func savePostWithCombine() {
let imageKey = UUID().uuidString + ".jpg"
// Label objects in image
let getImageTags = Amplify.Predictions.identify(type: .detectLabels(.labels), image: imageUrl)
.resultPublisher
.mapError { PostError.failedToGetTags(error: $0) }
// Upload image to storage
let uploadImage = Amplify.Storage.uploadFile(key: imageKey,local: imageUrl)
.resultPublisher
.mapError { PostError.failedToUploadImage(error: $0) }
token = Publishers.CombineLatest(getImageTags, uploadImage)
// Only save the post once image has been uploaded and object in
// the image have been identified
.flatMap { identifyResult, _ -> AnyPublisher<Post, PostError> in
let labelsResult = identifyResult as! IdentifyLabelsResult
let tags = labelsResult.labels.map(\.name)
let post = Post(imageKey: imageKey, tags: tags)
return Amplify.API.mutate(request: .create(post))
.resultPublisher
.tryMap { try $0.get() }
.mapError { PostError.failedToGetTags(error: $0) }
.eraseToAnyPublisher()
}
.sink(
receiveCompletion: { print($0) },
receiveValue: { print("Post saved - \($0)") }
)
}
Let’s take a peek at the new Combine APIs that are available by going through an example of what the code might look like for a social media app.
Sign Up
First things first, we can’t have a social media site without users, so let’s sign them up.
// 1
@State var signUpToken: AnyCancellable?
func signUp() {
// 2
signUpToken = Amplify.Auth.signUp(username: username, password: password)
// 3
.resultPublisher
// 4
.receive(on: DispatchQueue.main)
// 5
.sink(
// 6
receiveCompletion: { completion in
if case .failure(let error) = completion {
print("Sign in error: \(error)")
}
},
// 7
receiveValue: { result in
// 8
switch result.nextStep {
case .confirmUser:
break
case .done:
break
}
}
)
}
- Since we are working with Combine and Publishers, it is important that we always have a “token” object that will allow the publisher to stay alive even after the function has completed.
- We can see here that we are assigning a value to the token by starting off with the same function signature that we are already used to when using
Auth.signUp. - This is the publisher itself. In some cases we will have a
resultPublisherin others, we will see that the original function signature has been overloaded to return a Publisher. - Since our Sign Up flow will most-likely involve additional steps like confirmation, which is dealing directly with UI, we want to make sure that we handle the result on the main thread. If we didn’t intend to modify UI, omitting this step would be fine.
- Our sink is where we can observe what is actually going on in regards to the resulting value, errors, or the completion of the stream.
- Just like any Combine
sink, we can receive a completion on a stream, stopping it from emitting any more values. Errors also cause streams to complete and this is where we can handle them. - The
receivedValueis the object that we are looking for when the happy path succeeds. - The
resultis the same type as it would be if we were using closures/callbacks, meaning that this is anAuthSignUpResultwhich may or may not have anextStepthat needs to be handled.
Sign In
Once we have the user created in our backend, it’s time to let them sign into the app.
@State var signInToken: AnyCancellable?
func signIn() {
// 1
signInToken = Amplify.Auth.signIn(username: username, password: password)
.resultPublisher
.sink(
receiveCompletion: {
// 2
if case .failure(let error) = $0 {
print("Sign in error: \(error)")
}
},
receiveValue: { result in
// 3
print("Successful result: \(result)")
}
)
}
For the most part, the layout of the publishers will be similar to that of the Sign Up code. We do have a few differences though:
- We are using a seperate “token” to hang on to the reference of the
Auth.signInsink. - Instead of passing in
completionto the closure, I’ve decided to use the short hand to check if$0is an error. - Here we are simply printing out the result, but you would most likely want to do any additional work here while you still have access to the
usernameandpasswordof the user. In our case, we plan on usingHUBto handle state change.
Observe Session Status
If you like to keep things easier to maintain like we do, then we should use HUB to listen to the different Auth events and update the state accordingly.
@State var authHubToken: AnyCancellable?
func observeAuthEvents() {
// 1
authHubToken = Amplify.Hub.publisher(for: .auth)
// 2
.compactMap { payload -> Bool? in
let isSignedIn: Bool
switch payload.eventName {
case HubPayload.EventName.Auth.signedIn:
isSignedIn = true
case HubPayload.EventName.Auth.signedOut:
isSignedIn = false
default:
return nil
}
return isSignedIn
}
// 3
.receive(on: DispatchQueue.main)
// 4
.sink { isSignedIn in
if isSignedIn {
// handle sign in
} else {
// handle sign out
}
}
}
So now we are really starting to see some of the power of using Combine. Being able to take a complex object and transform it into the relevant value makes it so much easier to understand what’s going on in our code.
HUB.publisheris one of the APIs that are immediately returning a publisher on which we can perform operations likecompactMapandsink..compactMapis taking the payload provided byHub.publisherand transforming it into a simple Bool that we can use to determine the user’s session state..compactMapis much more useful in this situation than.mapbecause it allows us to returnnil, which prevents thesinkfrom firing during invalid events.- Up until this point, all our work has been performed on a background thread, but once we enter the
sinkwe will most likely be updating properties that affect UI, which is why we need to return to the main thread. - We take our simple
Boolvalue and update our user’s state accordingly.
Get Posts
Now that the user has signed in, we have to show them their Feed. It’s time to get those Post‘s
@State var getPostsToken: AnyCancellable?
func getPosts() {
// 1
getPostsToken = Amplify.DataStore.query(Post.self)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
// handle error
break
case .finished:
// handle completed stream
break
}
},
// 2
receiveValue: { posts in
// populate UI with posts
}
)
}
DataStore.queryis another API that has been overloaded to allow us to use it directly as a Publisher, so we can apply any relevant operators to it as we would any other Publisher.- Our
receiveValueblock is where we would handle thepostsand likely do something likeself.posts = postso our UI reflects what was provided by DataStore.
Observe Post Events
We could call getPosts() whenever we receive events that indicate there was a change in the data, but using DataStore.publisher makes it much more simple by allowing us to observe the specific change to the individual Post, making it easier to setup specific behavior for each type of change.
@State var observePostsToken: AnyCancellable?
func observePosts() {
// 1
observePostsToken = Amplify.DataStore.publisher(for: Post.self)
// 2
.compactMap { event -> (mutationType: MutationEvent.MutationType, post: Post)? in
guard
let mutationType = MutationEvent.MutationType(rawValue: event.mutationType),
let post = try? event.decodeModel(as: Post.self)
else { return nil }
return (mutationType, post)
}
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion {
// handle error
}
},
receiveValue: {
// 3
let (mutationType, post) = $0
// 4
switch mutationType {
case .create:
break
case .update:
break
case .delete:
break
}
}
)
}
DataStore.publisheris a Publisher, as its name suggests, and allows us to observe the different mutation events for a specifiedModeltype. In our case, we will be observing changes forPost.- We are using
compactMapagain to help filter out irrelevant data as well as change the output to a tuple(mutationType: MutationEvent.MutationType, post: Post). - Since the value is now a tuple, one way to interact with the values is to assign the values to constants by using
let (mutationType, post)which will map to the values of the tuple respectively. - Now that we’re working with
mutationType: MutationEvent.MutationTypewe can switch off the three different cases and update the UI accordingly using the proper animations.
Create Post
Finally, the most important part of our app, the ability to actually create a Post. This is a slightly more complex operation because we would have to upload the image to Storage and create a Post object in our database. We may also want to do something like log analytics whenever we successfully create a Post to help us understand more about our user’s and their posting habits.
@State var createPostToken: AnyCancellable?
func createPost() {
// 1
guard let imageData = image?.jpegData(compressionQuality: 0.5) else { return }
let key = UUID().uuidString + ".jpg"
// 2
createPostToken = Amplify.Storage.uploadData(key: key, data: imageData)
// 3
.resultPublisher
// 4
.mapError { CreatePostError.failedUpload(error: $0) }
// 5
.flatMap { _ in
Amplify.DataStore.save(
Post(
userId: userId,
imageKey: key,
caption: caption
)
)
// 6
.mapError { CreatePostError.failedSave(error: $0) }
}
// 7
.handleEvents(receiveOutput: { post in
let event = BasicAnalyticsEvent(name: "postCreated")
Amplify.Analytics.record(event: event)
})
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion {
// handle error
}
},
receiveValue: { post in
// 8
print("Created post: \(post)")
}
)
}
- We need the image data to upload to Storage, so we convert the
UIImageto JPG data with a compression of 0.5 so upload is much smoother. The amount of compression is totally up to you though. - Here we are using the
Storage.uploadDatawith thekeyandimageDatathat we just created. - We are using
resultPublisherhere becauseStorage.uploadDataprovides two different publishers:resultPublisherandprogressPublisher. I’m not going to implement the latter, but it would be a good publisher to use to let the user know how far along they are in the upload process. - Since we are chaining our operations (upload image > save
Post> record analytics event >sink), we need to make sure that we are working with a consistent error type throughout our chain. Thus, we use.mapErrorto convert theStorageErrorto a custom error type calledCreatePostError. - Another operator we need to use when chaining publishers is
.flatMap. This allows us to map the output of one publisher (Storage.uploadData.resultPublisher) to the output of another, in this case,DataStore.savewhich outputs a publisher of typeAnyPublisher<Post, DataStoreError> - Since we are inside
flatMapand need to stay consistent with the error through the entire chain, we need to usemapErrorto convert theDataStoreErrorto aCreatePostError. - Once we have gone through the chain, we want to record events whenever a user successfully posts to the Feed. This is where
.handleEventscomes in, specifically thereceiveOutputargument. When working withreceiveOutput, we have access to the desired output,Postin this case, and we can use any useful information about the post to include into our Analytics event. The example here doesn’t use any info from the Post but the event is still recorded with the basic info. - At the very end of the chain, we are provided with our saved
Postthanks to the output fromDataStore.save. We could do whatever we want with thisPost, or we can choose to simply ignore it since we will have observed the created event in ourobservePostspublisher.
Now depending on your coding style, you might be willing to wrap up the functionality of these chained publishers into their own functions. The end result could be something as condensed as this:
@State var createPostToken: AnyCancellable?
func createPost() {
let key = UUID().uuidString + ".jpg"
let post = Post(userId: userId, imageKey: key, caption: caption)
createPostToken = AnyPublisher<Post, CreatePostError>
.upload(image, key: key)
.save(post)
.recordEvent(.postCreated)
.sink(
receiveCompletion: { print($0) },
receiveValue: { print($0) }
)
}
Wrapping Up
There are still several use cases that weren’t covered in this article, but adopting them should be very straight forward since the APIs tend to follow similar patterns.
As reactive programming and declarative UI become more relevant in the native iOS space, it only makes sense to continue to work and grow with community expectations. Anything else just feels outdated ??♂️.
So now the only question is, “Are you going to start adding Combine to your Amplify projects”?