[レポート] MOB308: AWSを活用してクラウドに繋がったiOSゲームアプリを作る #reinvent

Build a Cloud-Connected iOS Game with AWS

re:Invent 2018にて、AWS Amplifyを使ってiOSで動作するゲームアプリを作るワークショップに参加してきました。以下が概要です。

As an iOS developer, you understand the frameworks like UIKit and ARKit. But how can you make your app more successful? AWS Mobile has some easy-to-use yet powerful cloud offerings that can help take your app to the next level. In this workshop, we extend an open-source iOS game with social authentication and leaderboards, and we gather analytics that you can use to engage users and enhance their experience.

本記事では、ワークショップで行った内容をレポートします。

Agenda

  • Game details
  • Toolchain
  • Architecture
  • Workshop
  • Takeaways

Game details

オープンソースゲームのFlappy BirdであるFlappySwiftforkしたリポジトリでゲームを作ります。

クラウドを利用して、以下の機能を作ります。

  • ユーザー登録、ログイン
  • ハイスコア
  • ユーザー解析

Toolchain

  • AWS Amplify CLI(https://bit.ly/AmplifyCLI)
    • ユーザー登録、ログイン
  • AWS CloudFormation
    • ハイスコアのAPIを構築
  • Xcode (9.2+)
  • CocoaPods

Architecture

  • Amazon Cognitoでユーザー登録、ログイン
  • AWS AppSyncでAmazon DynamoDBにハイスコアデータを保存
  • Amazon Pinpointでユーザー行動ログを取得

Workshop

以下のリポジトリをcloneして行います。

まずは起動する

まずはcloneしてXcodeで起動するところまで試します。

$ git clone git@github.com:rohandubal/FlappySwift.git
$ cd FlappySwift
$ open FlappyBird.xcodeproj

Xcodeが起動したら Cmd + R で起動。

おなじみのFlappy Bird!

Amplifyでサービスを立ち上げる

まずはAmplifyでCloudFormation実行用のIAM Userを作成します。

$ amplify configure

次に amplify init で初期化(IAM RoleやS3バケットの作成)を行います。

$ amplify init
Note: It is recommended to run this command from the root of your app directory
? Choose your default editor: Atom Editor
? Choose the type of app that you're building ios
Using default provider awscloudformation

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use workshop
⠏ Initializing project in the cloud...

CREATE_IN_PROGRESS lappywift-20181127085924 AWS::CloudFormation::Stack Tue Nov 27 2018 08:59:27 GMT-0800 (PST) User Initiated
CREATE_IN_PROGRESS UnauthRole               AWS::IAM::Role             Tue Nov 27 2018 08:59:32 GMT-0800 (PST)
CREATE_IN_PROGRESS AuthRole                 AWS::IAM::Role             Tue Nov 27 2018 08:59:32 GMT-0800 (PST)
CREATE_IN_PROGRESS DeploymentBucket         AWS::S3::Bucket            Tue Nov 27 2018 08:59:32 GMT-0800 (PST)
CREATE_IN_PROGRESS UnauthRole               AWS::IAM::Role             Tue Nov 27 2018 08:59:32 GMT-0800 (PST) Resource creation Initiated
CREATE_IN_PROGRESS AuthRole                 AWS::IAM::Role             Tue Nov 27 2018 08:59:32 GMT-0800 (PST) Resource creation Initiated
CREATE_IN_PROGRESS DeploymentBucket         AWS::S3::Bucket            Tue Nov 27 2018 08:59:33 GMT-0800 (PST) Resource creation Initiated
⠏ Initializing project in the cloud...

CREATE_COMPLETE AuthRole                 AWS::IAM::Role             Tue Nov 27 2018 08:59:44 GMT-0800 (PST)
CREATE_COMPLETE UnauthRole               AWS::IAM::Role             Tue Nov 27 2018 08:59:46 GMT-0800 (PST)
CREATE_COMPLETE DeploymentBucket         AWS::S3::Bucket            Tue Nov 27 2018 08:59:53 GMT-0800 (PST)
CREATE_COMPLETE lappywift-20181127085924 AWS::CloudFormation::Stack Tue Nov 27 2018 08:59:56 GMT-0800 (PST)
✔ Successfully created initial AWS cloud resources for deployments.

Your project has been successfully initialized and connected to the cloud!

Some next steps:
"amplify status" will show you what you've added already and if it's locally configured or deployed
"amplify <category> add" will allow you to add features like user login or a backend API
"amplify push" will build all your local backend resources and provision it in the cloud
"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud

Pro tip:
Try "amplify add api" to create a backend API and then "amplify publish" to deploy everything

CocoaPodsで各ライブラリをインストールする

CocoaPodsで各ライブラリをインストールします。

$ pod init
$ vim Podfile

Podfile を以下のようにします。

# Uncomment the next line to define a global platform for your project
platform :ios, '9.0'

  target 'FlappyBird' do
  # Comment the next line if you're not using Swift and don't want to use dynamic frameworks
  use_frameworks!

  # Pods for FlappyBird

  ####### AWS Frameworks - Begin Copy ######
  pod 'AWSMobileClient', '~> 2.7.0'
  pod 'AWSAuthUI', '~> 2.7.0'
  pod 'AWSUserPoolsSignIn', '~> 2.7.0'
  pod 'AWSAppSync', '~> 2.6.25'
  pod 'AWSPinpoint', '~> 2.7.0'
  ####### AWS Frameworks - End Copy ########

  target 'FlappyBirdTests' do
      inherit! :search_paths
      # Pods for testing
  end

end

あとは pod install で上記に記載したライブラリをインストールします。

$ pod install --repo-update
CocoaPods 1.6.0.beta.2 is available.
To update use: `gem install cocoapods --pre`
[!] This is a test version we'd love you to try.

For more information, see https://blog.cocoapods.org and the CHANGELOG for this version at https://github.com/CocoaPods/CocoaPods/releases/tag/1.6.0.beta.2

Analyzing dependencies
Downloading dependencies
Installing AWSAppSync (2.6.25)
Installing AWSAuthCore (2.7.3)
Installing AWSAuthUI (2.7.3)
Installing AWSCognitoIdentityProvider (2.7.3)
Installing AWSCognitoIdentityProviderASF (1.0.1)
Installing AWSCore (2.7.3)
Installing AWSMobileClient (2.7.3)
Installing AWSPinpoint (2.7.3)
Installing AWSUserPoolsSignIn (2.7.3)
Installing ReachabilitySwift (4.0.0)
Installing SQLite.swift (0.11.4)
Generating Pods project
Integrating client project

[!] Please close any current Xcode sessions and use `FlappyBird.xcworkspace` for this project from now on.
Sending stats
Pod installation complete! There are 5 dependencies from the Podfile and 11 total pods installed.

Amplifyでクラウドの機能を追加する

Amplify CLI経由で、AWSのリソースを準備していきます。

ユーザー認証

$ amplify add auth

こちらを実行すると設定について問われるので default configuration を選びます。

$ amplify push

Xcodeを開きます。

$ open FlappyBird.xcworkspace

設定が記載された awsconfiguration.json ファイルを FlappyBird グループの中にドラッグ&ドロップで追加します。

AppDelegate.swift の中身を以下に変えます。

import UIKit
import AWSMobileClient

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        self.initializeAWSMobileClient()
        return true
    }

    func initializeAWSMobileClient() {
        AWSMobileClient.sharedInstance().initialize { (userState, error) in
            if let userState = userState {
                print("UserState: \(userState.rawValue)")
            } else if let error = error {
                print("error: \(error.localizedDescription)")
            }
        }
    }

}

次に MainViewController.swift を以下に変えます。showSignInScreenIfNotLoggedIn() メソッドが主に追加した実装です。

import Foundation
import UIKit
import AWSMobileClient

class MainViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        self.navigationController?.navigationBar.tintColor = UIColor.white
        self.navigationController?.navigationBar.barTintColor = UIColor.purple
        self.showSignInScreenIfNotLoggedIn()
    }

    func showSignInScreenIfNotLoggedIn() {
        // If user is not signed in, we show the sign in screen.
        if(!AWSMobileClient.sharedInstance().isSignedIn) {
            AWSMobileClient.sharedInstance().showSignIn(navigationController: self.navigationController!) { (userState, error) in
                if (error == nil) {
                    print("User State is \(userState!.rawValue)")
                }
            }
        } else {
            // User is already logged in.
        }
    }

    @IBAction func onHighScoresClicked(_ sender: Any) {

    }

    @IBAction func onPlayClicked(_ sender: Any) {

    }

    @IBAction func onSignOutClicked(_ sender: Any) {
        AWSMobileClient.sharedInstance().signOut()
        self.showSignInScreenIfNotLoggedIn()
    }
}

これで動かすとログイン機能が使えるようになっています。

行動履歴

$ amplify add analytics
$ amplify push

AppDelegate.swift にPinpointの初期化が行われるようにコードを修正します。

import UIKit
import AWSMobileClient

// ここを追加
import AWSPinpoint

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    // ここを追加
    static var pinpoint: AWSPinpoint?

    func initializeAWSMobileClient() {
        AWSMobileClient.sharedInstance().initialize { (userState, error) in
            if let userState = userState {
                print("UserState: \(userState.rawValue)")
                // ここを追加
                AppDelegate.pinpoint = AWSPinpoint(configuration:
                    AWSPinpointConfiguration.defaultPinpointConfiguration(launchOptions: nil))
            } else if let error = error {
                print("error: \(error.localizedDescription)")
            }
        }
    }

    .....

MainViewController.swift も修正します。extentionでメソッドを追加。

// Mark: AWS Analytics Extension
extension MainViewController {

    // This function logs an event when the user clicks on `High Scores` button
    func logShowHighScore() {
        let event = AppDelegate.pinpoint?.analyticsClient.createEvent(withEventType: "ShowHighScores")
        AppDelegate.pinpoint?.analyticsClient.record(event!)
    }

    // This function logs an event when the user clicks on `Play` button
    func logPlayGame() {
        let event = AppDelegate.pinpoint?.analyticsClient.createEvent(withEventType: "PlayGame")
        AppDelegate.pinpoint?.analyticsClient.record(event!)
    }

    // This function logs an event when the user sees the main screen of app and records if they are a new user or returning user
    func logReturningOrNewUser() {
        let event = AppDelegate.pinpoint?.analyticsClient.createEvent(withEventType: "AppLaunch")
        if (AWSMobileClient.sharedInstance().isSignedIn) {
            event?.addAttribute("NewLogin", forKey: "EntryMechanism")
        } else {
            event?.addAttribute("PreviousLogin", forKey: "EntryMechanism")
        }
        AppDelegate.pinpoint?.analyticsClient.record(event!)
    }
}

既存のメソッドにログ送信処理を追加します。

override func viewDidLoad() {
    super.viewDidLoad()
    self.navigationController?.navigationBar.tintColor = UIColor.white
    self.navigationController?.navigationBar.barTintColor = UIColor.purple
    self.showSignInScreenIfNotLoggedIn()
    // ここを追加
    self.logReturningOrNewUser()
}

@IBAction func onHighScoresClicked(_ sender: Any) {
    // ここを追加
    self.logShowHighScore()
}

@IBAction func onPlayClicked(_ sender: Any) {
    // ここを追加
    self.logPlayGame()
}

@IBAction func onSignOutClicked(_ sender: Any) {
    AWSMobileClient.sharedInstance().signOut()
    self.showSignInScreenIfNotLoggedIn()
}

ハイスコアの記録

AppSync APIを作るためにCloudFormation Stackを新規作成します。

  1. CloudFormationのコンソールからスタックを新規作成
  2. リポジトリ内の ScoresGraphQLAPI.yaml をテンプレートに使う
  3. パラメータを設定する
    • graphQlApiName : FlappyBirdScoreAPI
    • userPoolId : 以下のコマンドで抽出
    • userPoolAwsRegion : userPoolId の前方

userPoolId は設定ファイルより以下のコマンドで抽出します。

$ grep -s "UserPoolId" amplify/backend/amplify-meta.json | awk '{print $2}'

Stackが起動できたらAppSync Consoleを開いてAPIのIDをコピーし、以下のコマンドを実行します。

$ amplify add codegen --apiId <API Id Here>

出来上がった API.swift はXcodeプロジェクト内に追加します。

AppDelegate.swift を修正します。

import UIKit
import AWSMobileClient
import AWSPinpoint

// ここを追加
import AWSAppSync

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    static var pinpoint: AWSPinpoint?

    // ここを追加
    static var appSyncClient: AWSAppSyncClient?

    func initializeAWSMobileClient() {
        AWSMobileClient.sharedInstance().initialize { (userState, error) in
            if let userState = userState {
                print("UserState: \(userState.rawValue)")
                AppDelegate.pinpoint = AWSPinpoint(configuration:
                    AWSPinpointConfiguration.defaultPinpointConfiguration(launchOptions: nil))

                // ここを追加
                self.initializeAppSync()
            } else if let error = error {
                print("error: \(error.localizedDescription)")
            }
        }
    }

    // ここを追加
    func initializeAppSync() {
        // You can choose your database location, accessible by the SDK
        let databaseURL = URL(fileURLWithPath:NSTemporaryDirectory()).appendingPathComponent("game_scores")

        do {
            // Initialize the AWS AppSync configuration
            let appSyncConfig = try AWSAppSyncClientConfiguration(appSyncClientInfo: AWSAppSyncClientInfo(),
                                                                  userPoolsAuthProvider: {
                                                                    class MyCognitoUserPoolsAuthProvider : AWSCognitoUserPoolsAuthProviderAsync {
                                                                        func getLatestAuthToken(_ callback: @escaping (String?, Error?) -> Void) {
                                                                            AWSMobileClient.sharedInstance().getTokens { (tokens, error) in
                                                                                if error != nil {
                                                                                    callback(nil, error)
                                                                                } else {
                                                                                    callback(tokens?.idToken?.tokenString, nil)
                                                                                }
                                                                            }
                                                                        }
                                                                    }
                                                                    return MyCognitoUserPoolsAuthProvider()}(),
                                                                  databaseURL:databaseURL)

            // Initialize the AWS AppSync client
            AppDelegate.appSyncClient = try AWSAppSyncClient(appSyncConfig: appSyncConfig)
        } catch {
            print("Error initializing appsync client. \(error)")
        }
    }

    .....

次に GameViewController.swift にExtensionを追加します。

// Mark: AWS Cloud Functions
extension GameViewController {
    func logGameFinishedEvent(score: Int) {
        let event = AppDelegate.pinpoint?.analyticsClient.createEvent(withEventType: "GamePlayed")
        event?.addMetric(score as NSNumber, forKey: "Score")
        AppDelegate.pinpoint?.analyticsClient.record(event!)
    }

    func submitScore(score: Int) {
        let newScore = CreateScoreInput(score: score, scoreDate: Int(Date().timeIntervalSince1970))
        let newScoreMutation = CreateScoreMutation(input: newScore)
        AppDelegate.appSyncClient?.perform(mutation: newScoreMutation)
    }
}

また、予め用意されている GameEnded プロトコルを実装しているExtensionを以下に修正します。

// Mark: Game ended actions
extension GameViewController: GameEnded {
    func gameEnded(score: Int) {
        showPopupWithOptions(title: "Game Finished!", message: "You scored \(score.description)! \nWhat do you want to do next?")
        /******* Begin copy code *********/
        self.logGameFinishedEvent(score: score)
        self.submitScore(score: score)
        /******* End copy code *********/
    }
}

最後に ScoresViewController.swift を修正します。まず以下のメソッドを追加します。

func loadTopScores() {
    let query = ListTopScoresQuery()
    // fetch from the cache first for a responsive UI
    AppDelegate.appSyncClient?.fetch(query: query, cachePolicy: .returnCacheDataAndFetch, resultHandler: { (result, error) in
        if let result = result {
            var localScores = [String]()
            guard result.data?.listTopScores != nil else {
                return
            }
            if result.data!.listTopScores!.items!.count > 0 {
                for item in result.data!.listTopScores!.items! {
                    localScores.append("\(item!.score) \t\t \(item!.username)")
                }
                self.scores = localScores
            }
        }
    })
}

このメソッドを viewDidLoad() で呼び出します。

override func viewDidLoad() {
    scoresTableView.delegate = self
    scoresTableView.dataSource = self
    self.scoresTableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseIdentifier)

    // Call loadTopScores to show the leader board
    self.loadTopScores()
}

以上で全ての実装が終わりです!

動かしてみる

Xcodeで Cmd + R で実行してみます。そして普通にFlappy Birdを楽しみます。

ハイスコアを見てみると記録されていることが確認できます。

まとめ

バックエンドを作りながらiOSを実装するのはなかなか大変ですが、Amplify CLIによってクラウド側のリソースがコマンド一発で用意できるのはかなり使い勝手が良いです。

しかしながら完全にAmplify CLIの機能だけで全てのAWSリソースが用意できるわけにはいかず、あくまで主要なサービスのリソースだけ作れるという形になっています。それ以外はやはりCloudFormationを直接使うというパターンになります。

また、AppSyncのcodegenについては以前はちょっと煩雑でしたが、こちらもAmplify CLIに含まれているので開発環境依存が減るなど、より効率的になっており好感が持てました。今後もデベロッパーにとってより使いやすくなるようなアップデートを期待しています!