過去記事
- Azure MobileApps + Xamarin.Forms開発の始め方(.NETバックエンド + Prism.Forms) - かずきのBlog@hatena
- Azure MobileApps + Xamarin.Forms開発の始め方(.NETバックエンド + Prism.Forms)「DBの変更」 - かずきのBlog@hatena
- Azure MobileApps + Xamarin.Forms開発の始め方(.NETバックエンド + Prism.Forms)「ローカルデバッグ」 - かずきのBlog@hatena
オフラインデータ同期をしよう
モバイルアプリの特徴として、常にネットワーク通信が良好な状態で操作されるわけではないという特徴があります。 そんなときのため、ローカルDBにいったんデータをため込んで、ネットワークが生きてるときにサーバーと同期するということが行われたりします。
Azure Mobile Appを使うと、ここら辺の機能がデフォルトで組み込まれているので捗ります。
オフライン同期をするには
下準備
Xamarin.Forms(Android, iOS)でオフライン同期を有効にするには、以下のパッケージをNuGetからPCLとAndroidとiOSのプロジェクトに追加します。
- Microsoft.Azure.Mobile.Client.SQLiteStore
2016/09/23現在、Xamarin.FormsのMobile Appsの同期のドキュメントは古くて、そこに書いてある通りに実行するとエラーになります(ひどい)
ちょっと処理が変わってて、SQLiteのパスを取得する処理がAndroidだとPersonalのパスをとってきて、そのファイルを作っておかないといけないという点と、iOSではSQLitePCL.Batteries.Init();
が必要そうだというところです。ここらへんは、Xamarin.AndroidとXamarin.iOSのドキュメントを個別にみると書いてあります。
パスの指定方法が、プラットフォーム固有になってます。なので、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); } }
こいつらの実装クラスは置いといて、MainPageViewModel
をTodoApp
を使うように変更します。
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環境にアクセスできる状態の間はローカル環境で動かせるのではないかと思います。(近い将来消すので動かなくなります)