XamarinでもAWS AppSyncを使いたい

遅くなりましたが、[Xamarin Advent Calendar 2019](https://qiita.com/advent-calendar/2019/xamarin)の17日目です。

以前にAppSyncを使う機会があって、今回XamarinでもAppSyncが使えるかを試してみました。

# AWS AppSyncとは
GraphQLを簡単に利用できるマネージドサービスです。データソースにはDynamoDBやLambdaなどが利用でき、リアルタイム更新やオフラインデータ同期にも対応しています。

# Xamarin対応
現在AppSyncがXamarinに対応しているかと言うと、**していません**。NuGetで探すと、[AWSSDK.AppSync](https://www.nuget.org/packages/AWSSDK.AppSync)というのがあるのですが、これは、AppSyncのプロジェクトの閲覧や更新用のAPIのライブラリの様なので使うことはできません。普通にiOSやAndroidのライブラリはあるので、それを使って自前でバインディングするしかなさそうです。

# バインディングライブラリの作成
公式で出ているAndroidやiOSのライブラリをそのままバインディングするのでも良かったのですが、正直問題が起きた時に面倒ですし、GraphQLのコードの自動生成もあるので、Android、iOSそれぞれで今回作るアプリで必要なAppSyncを利用するコードを書いて、それをバインディングすることにしました。

# 作ったサンプルアプリ
簡単なチャットアプリです。AppSyncの機能としては、メッセージの作成と作成通知を受け取るだけです。
ソースは、[ここ](https://github.com/f-miyu/XamarinAppSyncChatApp)です。

<img width="584" alt="スクリーンショット 2019-12-17 23.01.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236182/05dba6c0-3d57-f74d-b335-4281da7f865d.png">

# サンプルアプリを動かすには
AppSyncのプロジェクトを作る必要があります。AWSのコンソール上で作っていきます。
## プロジェクトの作成
AWSのコンソールからAWS AppSyncを選んで、表示画面の[APIを作成]ボタンをクリックします。

![スクリーンショット 2019-12-17 3.03.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236182/ea8c23af-3e85-f1e2-e2c3-7ce22a347445.png)

ウィザードで作成を選択して、開始をクリックします。

![スクリーンショット 2019-12-17 2.58.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236182/42812ada-d388-b81d-6134-4d54f9ec2552.png)

次の画面では以下の様に設定して、Createボタンをクリックします。

#### モデル名
Message

#### モデルフィールドの設定

|名前|タイプ|リスト|必須|
|---|---|---|---|
|id|ID||✔︎|
|chatId|String||✔︎|
|text|String||✔︎|
|userId|String||✔︎|

#### モデルテーブルを設定 (オプション)
テーブル名
MessageTable

プライマリーキー
chatId

ソートキー
Id

![スクリーンショット 2019-12-17 3.08.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236182/a798286c-e66c-6710-9111-633e32c2daff.png)

API名を設定して、作成ボタンをクリックします。

![スクリーンショット 2019-12-17 3.36.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236182/b6b68164-4db2-67e6-61ab-cf70df106e69.png)

## プロジェクトの編集

スキーマ画面を表示し、作成されているスキーマを以下の様に変更して、スキーマを保存をクリックします。

```graphql
input CreateMessageInput {
	chatId: String!
	text: String!
	userId: String!
}

input DeleteMessageInput {
	id: ID!
	chatId: String!
}

type Message {
	id: ID!
	chatId: String!
	text: String!
	userId: String!
}

type MessageConnection {
	items: [Message]
	nextToken: String
}

type Mutation {
	createMessage(input: CreateMessageInput!): Message
	updateMessage(input: UpdateMessageInput!): Message
	deleteMessage(input: DeleteMessageInput!): Message
}

type Query {
	getMessage(chatId: String!, id: ID!): Message
	listMessages(filter: TableMessageFilterInput, limit: Int, nextToken: String): MessageConnection
}

type Subscription {
	onCreateMessage(chatId: String!): Message
		@aws_subscribe(mutations: ["createMessage"])
	onUpdateMessage(chatId: String!): Message
		@aws_subscribe(mutations: ["updateMessage"])
	onDeleteMessage(chatId: String!): Message
		@aws_subscribe(mutations: ["deleteMessage"])
}

input TableBooleanFilterInput {
	ne: Boolean
	eq: Boolean
}

input TableFloatFilterInput {
	ne: Float
	eq: Float
	le: Float
	lt: Float
	ge: Float
	gt: Float
	contains: Float
	notContains: Float
	between: [Float]
}

input TableIDFilterInput {
	ne: ID
	eq: ID
	le: ID
	lt: ID
	ge: ID
	gt: ID
	contains: ID
	notContains: ID
	between: [ID]
	beginsWith: ID
}

input TableIntFilterInput {
	ne: Int
	eq: Int
	le: Int
	lt: Int
	ge: Int
	gt: Int
	contains: Int
	notContains: Int
	between: [Int]
}

input TableMessageFilterInput {
	id: TableIDFilterInput
	chatId: TableStringFilterInput
	text: TableStringFilterInput
	userId: TableStringFilterInput
}

input TableStringFilterInput {
	ne: String
	eq: String
	le: String
	lt: String
	ge: String
	gt: String
	contains: String
	notContains: String
	between: [String]
	beginsWith: String
}

input UpdateMessageInput {
	id: ID!
	chatId: String!
	text: String
	userId: String
}
```


![スクリーンショット 2019-12-17 22.25.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236182/7e4f0684-c3c0-93e9-2c41-85d983585950.png)

右側のResolversから、createMessageのRssolverをクリックして、リゾルバー編集画面を開き、リクエストマッピングテーブルを以下の様に編集して、保存します。

```
{
  "version": "2017-02-28",
  "operation": "PutItem",
  "key": {
    "chatId": $util.dynamodb.toDynamoDBJson($ctx.args.input.chatId),
    "id": $util.dynamodb.toDynamoDBJson($util.autoId()),
  },
  "attributeValues": $util.dynamodb.toMapValuesJson($ctx.args.input),
  "condition": {
    "expression": "attribute_not_exists(#chatId) AND attribute_not_exists(#id)",
    "expressionNames": {
      "#chatId": "chatId",
      "#id": "id",
    },
  },
}
```

![スクリーンショット 2019-12-17 3.43.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236182/5ff17d8a-8825-f6d1-a414-bb42ec94f0e7.png)

## awsconfiguration.jsonファイルの追加

プロジェクトのトップ画面から、設定をダンロードをクリックして、awsconfiguration.jsonファイルをダウンロードします。（iOSもAndroidからでも同じファイルです）

![スクリーンショット 2019-12-17 22.29.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236182/c0561e79-4717-27b1-24a2-b99bcb32aa34.png)

ダウンロードしたファイルをXamarinAppSyncChatApp.Androidプロジェクトには、Resources/rawに追加して、ビルドアクションは、`AndroidResources`にします。
XamarinAppSyncChatApp.iOSプロジェクトには、ルートに追加して、ビルドアクションは、`BundleResource`にします。

これでビルドすれば動くはずです。

# バインディングの作成方法について
今回行ったバイディングライブラリ作成方法について説明します。なお、AppSyncの使い方しては特に触れないので、気になる方は[公式のドキュメント](https://aws-amplify.github.io/docs/)を参照してください。
## Android
### AARの作成
Android Studioで、プロジェクトを作り、Android Libraryのモジュールを追加して、AARファイルを作成しました。

<img width="1012" alt="スクリーンショット 2019-12-17 22.37.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236182/cd8c00f4-2a4f-46ce-2c9d-6831f224a3ab.png">

ソースはKotlinで書いています。以下がメインのコードです。

```kotlin
class AppSyncChatClient(context: Context) {
    private val client: AWSAppSyncClient = AWSAppSyncClient.builder()
        .context(context)
        .awsConfiguration(AWSConfiguration(context))
        .build()

    fun createMessage(chatId: String, text: String, userId: String, callback: Callback<Message?>?) {
        val input = CreateMessageInput.builder()
            .chatId(chatId)
            .text(text)
            .userId(userId)
            .build()
        val mutation = CreateMessageMutation.builder()
            .input(input)
            .build()

        client.mutate(mutation)
            .enqueue(object : GraphQLCall.Callback<CreateMessageMutation.Data>() {
                override fun onFailure(e: ApolloException) {
                    callback?.onFailure(e)
                }

                override fun onResponse(response: Response<CreateMessageMutation.Data>) {
                    val message = response.data()?.createMessage()?.let {
                        Message(
                            id = it.id(),
                            chatId = it.chatId(),
                            text = it.text(),
                            userId = it.userId()
                        )
                    }
                    callback?.onResponse(message)
                }
            })
    }

    fun onCreateMessage(chatId: String, callback: SubscriptionCallback<Message?>?): Cancelable {
        val subscription = OnCreateMessageSubscription.builder()
            .chatId(chatId)
            .build()
        val subscriptionWatcher = client.subscribe(subscription)
        subscriptionWatcher.execute(object :
            AppSyncSubscriptionCall.Callback<OnCreateMessageSubscription.Data> {
            override fun onFailure(e: ApolloException) {
                callback?.onFailure(e)
            }

            override fun onResponse(response: Response<OnCreateMessageSubscription.Data>) {
                val message = response.data()?.onCreateMessage()?.let {
                    Message(
                        id = it.id(),
                        chatId = it.chatId(),
                        text = it.text(),
                        userId = it.userId()
                    )
                }
                callback?.onResponse(message)
            }

            override fun onCompleted() {
                callback?.onCompleted()
            }
        })

        return object : Cancelable {
            override fun cancel() {
                subscriptionWatcher.cancel()
            }
        }
    }
}
```

### バインディング
Visual Studioで、Androidのバインディングライブラリのプロジェクト（AppSyncChatLibraryBinding.Android）を追加し、作成したAARファイルをJarsフォルダに入れます。AARファイルのビルドアクションは、`LibraryProjectZip`にします。これでビルドすれば、バインディングライブラリができます。

### Xamarin.Kotlin.StdLibの追加
今回Kotlinを使っているので、[Xamarin.Kotlin.StdLib](https://www.nuget.org/packages/Xamarin.Kotlin.StdLib/)をアプリのプロジェクトに追加します。

### Jar、AARの追加
元々参照していたAppSyncなどのJarファイルやAARファイルもアプリのプロジェクトに含めてやる必要があります。ビルドアクションは、Jarファイルは、`AndroidJavaLibrary`、AARファイルは、`AndroidAarLibrary`にします。

## iOS
### フレームワークの作成
Xcodeでフレームワークのプロジェクトを作成しました。

<img width="1226" alt="スクリーンショット 2019-12-17 22.46.48.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236182/efa3ffdc-631a-2f84-db09-246ced392dd9.png">

ソースはSwiftで書いています。以下がメインのコードです。

```swift
@objc(AppSyncChatClient)
public class AppSyncChatClient : NSObject {
    private var client: AWSAppSyncClient?
    
    public override init() {
        do {
            let cacheConfiguration = try AWSAppSyncCacheConfiguration()
            let appSyncConfig = try AWSAppSyncClientConfiguration(appSyncServiceConfig: AWSAppSyncServiceConfig(), cacheConfiguration: cacheConfiguration)
            client = try AWSAppSyncClient(appSyncConfig: appSyncConfig)
        } catch {
            print("Error initializing appsync client. \(error)")
        }

        super.init()
    }
    
    @objc public func createMessage(chatId: String, text: String, userId: String, onResult: @escaping (_ message: Message?) -> Void,  onError: @escaping (_ error: Error) -> Void) {
        let input = CreateMessageInput(chatId: chatId, text: text, userId: userId)
        let mutation = CreateMessageMutation(input: input)
        client?.perform(mutation: mutation) { (result, error) in
            guard error == nil else {
                onError(error!)
                return
            }
            
            var message: Message? = nil
            if let createMessage = result?.data?.createMessage {
                message = Message(id: createMessage.id, chatId: createMessage.chatId, text: createMessage.text, userId: createMessage.userId)
            }
            onResult(message)
        }
    }
    
    @objc public func onCreateMessage(chatId: String, onResult: @escaping (_ message: Message?) -> Void,  onError: @escaping (_ error: Error) -> Void) throws -> Subscription {
        let subscription = OnCreateMessageSubscription(chatId: chatId)
        let subscriptionWatcher = try client?.subscribe(subscription: subscription) { (result, transaction, error) in
            guard error == nil else {
                onError(error!)
                return
            }
            
            var message: Message? = nil
            if let onCreateMessage = result?.data?.onCreateMessage {
                message = Message(id: onCreateMessage.id, chatId: onCreateMessage.chatId, text: onCreateMessage.text, userId: onCreateMessage.userId)
            }
            onResult(message)
        }
        
        return Subscription(cancellable: subscriptionWatcher)
    }
}
```

Xamarinで利用するクラスや関数には、`@objc`をつけます。クラスには`@objc(AppSyncChatClient)`を指定しています。名前も指定してあげないとObjective-Cでは名前が変わってしまいます。

Build SettingsのEnable BitcodeはNoにします。

![スクリーンショット 2019-12-17 22.52.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236182/a97f1823-7a55-a49c-633a-255f82e279ef.png)

対象デバイスをGeneric iOS Deviceにして、実機用のを、シミュレータにしてシミュレータ用のをそれぞれビルドします。

### フレームワークの合成
実機用のフレームワークとシミュレータ用のフレームワークを合成します。以下のコマンドを実行して、実機用のフレームワークに出力していいます。

```sh
lipo -create Release-iphonesimulator/AppSyncChatLibrary_iOS.framework/AppSyncChatLibrary_iOS Release-iphoneos/AppSyncChatLibrary_iOS.framework/AppSyncChatLibrary_iOS -output AppSyncChatLibrary_iOS.framework/AppSyncChatLibrary_iOS
```
また、シミュレータのフレームワークのModules/AppSyncChatLibrary_iOS.swiftmoduleフォルダにあるi386やx86_64のswiftmoduleも含めてあげる必要があります。
参照しているフレームワークに対しても同様のことを行います。

### バインディング
iOSのバインディングライブラリのプロジェクト(（AppSyncChatLibraryBinding.Android）)を作成して、ネイティブ参照にフレームワークを全て追加します。
ApiDefinitionは、Objective Sharpieを使って作成します。ヘッダーファイルはフレームワークのHeadersフォルダのAppSyncChatLibrary_iOS-Swift.hを参照して、以下の様なコマンドを叩けば作成できます。

```sh
sharpie bind -sdk iphoneos13.2 AppSyncChatLibrary_iOS.framework/Headers/AppSyncChatLibrary_iOS-Swift.h 
```
作成されたApiDefinitionは大きなものができてしまいますが、作成したクラスのところだけコピーして、プロジェクトのApiDefinitionに貼り付けました。

### Xamarin.Swiftの追加
swiftのライブラリを利用するためには、[Xamarin.Swift](https://www.nuget.org/packages/Xamarin.Swift/)が必要なので、アプリのプロジェクトに追加します。

# まとめ
非常に面倒ではありましたが、XamarinでもAppSyncを利用することができました。今回の様に使いたいサービスがXamarinに対応していなくても、アプリケーションで利用したいものだけを抽出してライブラリ化したものをバインディングするのは、元々のライブラリを丸々バインディングするよりかは問題が起きても対処しやすいのではないかと思います。