かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Azure MobileApps + Xamarin.Forms開発の始め方(.NETバックエンド + Prism.Forms)「オフラインデータ同期」

過去記事

オフラインデータ同期をしよう

モバイルアプリの特徴として、常にネットワーク通信が良好な状態で操作されるわけではないという特徴があります。 そんなときのため、ローカルDBにいったんデータをため込んで、ネットワークが生きてるときにサーバーと同期するということが行われたりします。

Azure Mobile Appを使うと、ここら辺の機能がデフォルトで組み込まれているので捗ります。

オフライン同期をするには

下準備

Xamarin.Forms(Android, iOS)でオフライン同期を有効にするには、以下のパッケージをNuGetからPCLとAndroidとiOSのプロジェクトに追加します。

  • Microsoft.Azure.Mobile.Client.SQLiteStore

2016/09/23現在、Xamarin.FormsのMobile Appsの同期のドキュメントは古くて、そこに書いてある通りに実行するとエラーになります(ひどい)

azure.microsoft.com

ちょっと処理が変わってて、SQLiteのパスを取得する処理がAndroidだとPersonalのパスをとってきて、そのファイルを作っておかないといけないという点と、iOSではSQLitePCL.Batteries.Init();が必要そうだというところです。ここらへんは、Xamarin.AndroidとXamarin.iOSのドキュメントを個別にみると書いてあります。

azure.microsoft.com

azure.microsoft.com

パスの指定方法が、プラットフォーム固有になってます。なので、DependencyServiceを使うか、Prism.FormsならPlatformInitializerを使う必要があります。 今回は、Prism.Formsを使ってるのでPlatformInitializerを使いたいと思います。

ISQLiteDBPathProviderというインターフェースを作成します。

namespace PrismMobileApp.Models
{
    public interface ISQLiteDBPathProvider
    {
        string GetPath();
    }
}

Droidプロジェクトで、以下のような実装を準備します。

using System;
using PrismMobileApp.Models;
using System.IO;

namespace PrismMobileApp.Droid.Models
{
    class SQLiteDBPathProvider : ISQLiteDBPathProvider
    {
        public string GetPath()
        {
            var path = Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.Personal),
                "localstore.db");
            if (!File.Exists(path))
            {
                File.Create(path).Dispose();
            }

            return path;
        }
    }
}

iOSプロジェクトで以下のような実装を準備します。

using PrismMobileApp.Models;

namespace PrismMobileApp.iOS.Models
{
    class SQLiteDBPathProvider : ISQLiteDBPathProvider
    {
        public string GetPath()
        {
            SQLitePCL.Batteries.Init();
            return "localstore.db";
        }
    }
}

そして、各々のPlatformInitializerで以下のように登録します。

// Droid
public class AndroidInitializer : IPlatformInitializer
{
    public void RegisterTypes(IUnityContainer container)
    {
        container.RegisterType<IAuthenticator, Authenticator>(new ContainerControlledLifetimeManager());
        container.RegisterType<ISQLiteDBPathProvider, SQLiteDBPathProvider>(new ContainerControlledLifetimeManager());
    }
}


// iOS
public class iOSInitializer : IPlatformInitializer
{
    public void RegisterTypes(IUnityContainer container)
    {
        container.RegisterType<IAuthenticator, Authenticator>(new ContainerControlledLifetimeManager());
        container.RegisterType<ISQLiteDBPathProvider, SQLiteDBPathProvider>(new ContainerControlledLifetimeManager());
    }
}

これで下準備は完了です。

大規模リファクタリング

処理が複雑になってきたので、ちょっくらリファクタリングします。

using Microsoft.WindowsAzure.MobileServices;
using Microsoft.WindowsAzure.MobileServices.SQLiteStore;
using Prism.Mvvm;
using System.Collections.ObjectModel;
using System.Threading.Tasks;

namespace PrismMobileApp.Models
{
    public class TodoApp : BindableBase
    {
        private MobileServiceClient Client { get; }

        private ISQLiteDBPathProvider SQLiteDBPathProvider { get; }

        private ITodoItemRepository TodoItemRepository { get; }

        private IDataSynchronizer DataSynchronizer { get; }

        private IAuthenticator Authenticator { get; }

        public ObservableCollection<TodoItem> TodoItems { get; } = new ObservableCollection<TodoItem>();

        private bool isAuthenticated;

        public bool IsAuthenticated
        {
            get { return this.isAuthenticated; }
            set { this.SetProperty(ref this.isAuthenticated, value); }
        }

        private bool IsInitialized { get; set; }


        public TodoApp(MobileServiceClient client, 
            ISQLiteDBPathProvider sqliteDBPathProvider, 
            IAuthenticator authenticator,
            ITodoItemRepository todoItemRepository,
            IDataSynchronizer dataSynchronizer)
        {
            this.Client = client;
            this.Authenticator = authenticator;
            this.SQLiteDBPathProvider = sqliteDBPathProvider;
            this.TodoItemRepository = todoItemRepository;
            this.DataSynchronizer = dataSynchronizer;
        }

        public async Task InitializeAsync()
        {
            if (this.IsInitialized) { return; }

            var store = new MobileServiceSQLiteStore(this.SQLiteDBPathProvider.GetPath());
            store.DefineTable<TodoItem>();
            await this.Client.SyncContext.InitializeAsync(store);
            this.IsInitialized = true;
        }

        public async Task AuthenticateAsync()
        {
            if (this.IsAuthenticated) { return; }

            this.IsAuthenticated = await this.Authenticator.AuthenticateAsync();
        }

        public Task SyncAsync() => this.DataSynchronizer.SyncAsync();

        public async Task LoadTodoItemsAsync()
        {
            var todoItems = await this.TodoItemRepository.GetAllAsync();
            this.TodoItems.Clear();
            foreach (var item in todoItems)
            {
                this.TodoItems.Add(item);
            }
        }

        public async Task InsertTodoItemAsync(TodoItem todoItem)
        {
            await this.TodoItemRepository.InsertAsync(todoItem);
            await this.LoadTodoItemsAsync();
        }
    }
}

こんなメインのModelを用意します。InitializeAsyncの中身でローカル同期の初期化を行っています。

var store = new MobileServiceSQLiteStore(this.SQLiteDBPathProvider.GetPath());
store.DefineTable<TodoItem>();
await this.Client.SyncContext.InitializeAsync(store);

この処理は、先ほど作成したプラットフォーム固有のSQLiteDBPathProviderクラスからパスを取得して、それを使ってMobileServiceSQLiteStoreクラスを生成します。DefineTableメソッドでテーブル定義の初期化を行います。

このTodoAppクラス内で使われてるIDataSynchornizerと、ITodoItemRepositoryが新顔です。以下のような定義になります。

using System.Threading.Tasks;

namespace PrismMobileApp.Models
{
    public interface IDataSynchronizer
    {
        Task SyncAsync();
    }
}
using System.Collections.Generic;
using System.Threading.Tasks;

namespace PrismMobileApp.Models
{
    public interface ITodoItemRepository
    {
        Task<IEnumerable<TodoItem>> GetAllAsync();

        Task InsertAsync(TodoItem todoItem);
    }
}

こいつらの実装クラスは置いといて、MainPageViewModelTodoAppを使うように変更します。

using Prism.Commands;
using Prism.Mvvm;
using Prism.Navigation;
using PrismMobileApp.Models;
using System.Collections.ObjectModel;

namespace PrismMobileApp.ViewModels
{
    public class MainPageViewModel : BindableBase, INavigationAware
    {
        private TodoApp TodoApp { get; }

        private string text;

        public string Text
        {
            get { return this.text; }
            set { this.SetProperty(ref this.text, value); }
        }

        private bool isRefreshing;

        public bool IsRefreshing
        {
            get { return this.isRefreshing; }
            set { this.SetProperty(ref this.isRefreshing, value); }
        }

        public ObservableCollection<TodoItem> TodoItems => this.TodoApp.TodoItems;

        public DelegateCommand AddCommand { get; }

        public DelegateCommand RefreshCommand { get; }

        public MainPageViewModel(TodoApp todoApp)
        {
            this.TodoApp = todoApp;

            this.AddCommand = new DelegateCommand(async () =>
                {
                    await this.TodoApp.InsertTodoItemAsync(new TodoItem { Text = this.Text });
                    this.Text = "";
                    await this.TodoApp.LoadTodoItemsAsync();
                }, () => !string.IsNullOrWhiteSpace(this.Text) && this.TodoApp.IsAuthenticated)
                .ObservesProperty(() => this.Text);

            this.RefreshCommand = new DelegateCommand(async () =>
            {
                await this.TodoApp.SyncAsync();
                await this.TodoApp.LoadTodoItemsAsync();
                this.IsRefreshing = false;
            });
        }

        public void OnNavigatedFrom(NavigationParameters parameters)
        {

        }

        public async void OnNavigatedTo(NavigationParameters parameters)
        {
            await this.TodoApp.InitializeAsync();
            await this.TodoApp.AuthenticateAsync();
            await this.TodoApp.SyncAsync();
            await this.TodoApp.LoadTodoItemsAsync();
        }
    }
}

同期処理

IDataSynchronizerの実装クラスに同期処理を書きます。同期処理は、プルとプッシュになります。プッシュがローカルで行われた変更を順次サーバーに反映していきます。これはデータ全体に対して行われます。プルは、サーバーからの変更を取り込みます。この取り込みは、テーブル単位で行います。プルするときに、プッシュしないといけないものがある場合は暗黙的にプッシュがされるという動きをします。

ということで、コードで簡単な同期処理を書くと以下のようになります。

using Microsoft.WindowsAzure.MobileServices;
using Microsoft.WindowsAzure.MobileServices.Sync;
using System;
using System.Diagnostics;
using System.Threading.Tasks;

namespace PrismMobileApp.Models
{
    class DataSynchronizer : IDataSynchronizer
    {
        private MobileServiceClient Client { get; }

        private IMobileServiceSyncTable<TodoItem> TodoItemTable { get; }

        public DataSynchronizer(MobileServiceClient client)
        {
            this.Client = client;
            this.TodoItemTable = this.Client.GetSyncTable<TodoItem>();
        }

        public async Task SyncAsync()
        {
            try
            {
                await this.Client.SyncContext.PushAsync();
                await this.TodoItemTable.PullAsync("allTodoItem", this.TodoItemTable.CreateQuery());
            }
            catch (MobileServicePushFailedException ex)
            {
                var errors = ex.PushResult?.Errors;
                if (errors != null)
                {
                    foreach (var error in errors)
                    {
                        if (error.OperationKind == MobileServiceTableOperationKind.Update && error.Result != null)
                        {
                            await error.CancelAndUpdateItemAsync(error.Result);
                        }
                    }
                }
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex);
            }
        }
    }
}

プッシュは、MobileServiceClientクラスのSyncContextに対してPushAsyncを呼ぶことで実行できます。 プルは、MobileServiceClientからGetSyncTableで取得したテーブルに対してPullAsyncを呼び出すことで実行できます。PullAsyncは、クエリ名とクエリを受け取ります。クエリ名を指定すると、最後に同期した後にサーバーで行われた変更を取り込む動きをします。nullを指定すると全同期になるみたいです。クエリ名は、一意になる必要があるっぽいです。

プッシュに失敗したら、MobileServicePushFailedExceptionが発生します。この例外をハンドリングすることでエラーに対していろいろできます。今回は更新処理に失敗して、サーバーからデータが返されたらコンフリクトしてるということなので、ローカルのデータをサーバーのデータで上書きしています。さよならローカル変更。それ以外は無視しています。

次に、ローカルDBに対する読み込みと書き込みをやります。

using Microsoft.WindowsAzure.MobileServices;
using Microsoft.WindowsAzure.MobileServices.Sync;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace PrismMobileApp.Models
{
    class TodoItemRepository : ITodoItemRepository
    {
        private IMobileServiceSyncTable<TodoItem> TodoItemTable { get; }

        public TodoItemRepository(MobileServiceClient client)
        {
            this.TodoItemTable = client.GetSyncTable<TodoItem>();
        }

        public Task<IEnumerable<TodoItem>> GetAllAsync()
        {
            return this.TodoItemTable.CreateQuery().OrderBy(x => x.Text).ToEnumerableAsync();
        }

        public Task InsertAsync(TodoItem todoItem)
        {
            return this.TodoItemTable.InsertAsync(todoItem);
        }
    }
}

これは、MobileServiceClientクラスにGetSyncTableで取得できるIMobileServiceSyncTableクラスを使うことで実現できます。使い方はGetTableで取得したIMobileServyceTableと同じ感覚で使えます。

コンテナへの登録

最後に、コンテナでいい感じにModelのクラスとかを組み立ててくれるように登録を行います。

using Microsoft.Practices.Unity;
using Microsoft.WindowsAzure.MobileServices;
using Prism.Unity;
using PrismMobileApp.Models;
using PrismMobileApp.Views;
using System;
using System.Net.Http;

namespace PrismMobileApp
{
    public partial class App : PrismApplication
    {
        public App(IPlatformInitializer initializer = null) : base(initializer) { }

        protected override async void OnInitialized()
        {
            InitializeComponent();
            await this.NavigationService.NavigateAsync("MainPage");
        }

        protected override void RegisterTypes()
        {
            this.Container.RegisterTypeForNavigation<MainPage>();
            // MobileServiceClientをシングルトンで登録
            this.Container.RegisterType<MobileServiceClient>(
                new ContainerControlledLifetimeManager(),
                new InjectionConstructor(
                    //new Uri("https://okazuki0920.azurewebsites.net"),
                    new Uri("http://169.254.80.80:1782/"),
                    new HttpMessageHandler[] { }),
                new InjectionProperty(nameof(MobileServiceClient.AlternateLoginHost), new Uri("https://okazuki0920.azurewebsites.net")));
            this.Container.RegisterType<TodoApp>(new ContainerControlledLifetimeManager());
            this.Container.RegisterType<IDataSynchronizer, DataSynchronizer>(new ContainerControlledLifetimeManager());
            this.Container.RegisterType<ITodoItemRepository, TodoItemRepository>(new ContainerControlledLifetimeManager());
        }
    }
}

Visual Studio Emuletor for AndroidでローカルサーバーにつなぐようにURLは構成しています。(iOS動かせる環境がないので未検証)

これで実行すると同期処理を実行するまでサーバーにデータが反映されないことが確認できます。

ここまでのソースをGitHubにあげておきました。私のAzure環境にアクセスできる状態の間はローカル環境で動かせるのではないかと思います。(近い将来消すので動かなくなります)

github.com