使用 Xamarin.Essentials 進行檔案的同步或非同步存取
Xamarin.Essentials 提供非常多豐富好用的功能,其中一個 [檔案系統協助程式] 功能,可以用於在應用程式所在的儲存空間內使用快取與資料目錄存取功能,一旦取得了該特定目錄下的檔案之後,就可以使用 .NET Framework 的 System.IO 命名空間下的 API,進行目錄的新增、建立或刪除,也可以針對檔案進行建立、寫入、刪除的功能。
當在開發一個行動應用 App 的時候,往往需要能夠將這個 App 當時的執行狀態寫入到裝置內,等到下次這個 App 再度重新啟動的時候,可以從裝置的儲存體中將資料讀取回來。絕大部分的行動開發者應該都會直接想使用到 SQLite 來做到這樣的事情,但是作者卻比較偏好輕巧的解決方案,那就是把程式中的 .NET 物件經過 Json.NET 序列化之後,把這些 JSON 文字寫入到檔案內;當想要取回的時候,只需要從檔案把這些字串讀取出來,接著透過 Json.NET 反序列化這些 JSON 字串,這樣,當初的 .NET 中使用的物件,就還原回來了。
由於 Xamarin.Forms 屬於一個 GUI 類型的應用程式,也就是說,所有要針對 UI 相關控制項變更的行為需求,都需要在 UI 執行緒 Thread 下來進行執行,否則會發生例外異常;因此,當要進行檔案存取的動作是在 UI 執行緒執行的時候,會有可能因為檔案存取的動作需要花費些時間,因此,會造成整個應用程式的畫面不太流暢;故,這個時候可以將這些檔案 I/O 相關的動作,以非同步的方式來使用,就可以解決這些問題。
在這個範例中,將會說明如何使用同步與非同步的方式來進行檔案的存取。
建立一個 使用 Xamarin.Essentials 進行檔案存取的 專案
- 開啟 Visual Studio 2019 程式
- 當 Visual Studio 2019 開始 視窗 出現之後,請點選左下角的 [建立新專案] 選項
- 當 [建立新專案] 對話窗出現之後,請在中間最上方的搜尋文字輸入盒中輸入 [prism] 關鍵字,搜尋所有與 Prism 有關的專案樣板
- 請選擇 [Prism Blank App (Xamarin.Forms)] 這個專案樣板
- 當出現 [設定新的專案] 對話窗,請在 [專案名稱] 輸入 [FileAccess]
- 最後點選該對話窗右下方的 [建立] 按鈕
- 現在將會看到 [PRISM PROJECT WIZARD] 對話窗,請勾選 ANDROID, iOS, UWP 三個行動裝置平台,接著在底下 [Container] 下拉選單,選擇 Unity 項目
- 最後,點選 [CREATE PROJECT] 按鈕,以便產生 Xamarin.Forms 專案
安裝需要用到的 PropertyChanged.Fody NuGet 套件
- 當這個 Xamarin.Forms 專案建立成功之後,請在該方案中,找到 Xamarin.Forms 使用的專案,請在該專案中,使用滑鼠右擊 [相依性] 節點,選擇 [管理 NuGet 套件] 選項
- 在 [NuGet: XXX] 視窗中,點選 [瀏覽] 標籤頁次,並且在下方的搜尋文字輸入盒中,輸入 [propertychanged.fody] 關鍵字,搜尋出這個 NuGet 套件
- 當出現 [PropertyChanged.Fody] NuGet 套件,請點選該套件,並且點選右方的 [安裝] 按鈕,將這個套件安裝到 Xamarin.Forms 專案內
- 請查看 Xamarin.Forms 專案內,並沒有 [FodyWeavers.xml] 這個檔案,因此,使用滑鼠右擊 Xamarin.Forms 專案節點,選擇 [建置] 選項
- 當建置完成之後,在這個 Xamarin.Forms 專案內將會出現 [FodyWeavers.xml] 檔案
安裝需要用到的 Newtonsoft.Json NuGet 套件
- 在 [NuGet: XXX] 視窗中,搜尋文字輸入盒中,輸入 [Newtonsoft.Json] 關鍵字,搜尋出這個 NuGet 套件
- 當出現 [Newtonsoft.Json] NuGet 套件,請點選該套件,並且點選右方的 [安裝] 按鈕,將這個套件安裝到 Xamarin.Forms 專案內
安裝需要用到的 Xamarin.Essentials NuGet 套件
- 在 [NuGet: XXX] 視窗中,搜尋文字輸入盒中,輸入 [Xamarin.Essentials] 關鍵字,搜尋出這個 NuGet 套件
- 當出現 [Xamarin.Essentials] NuGet 套件,請點選該套件,並且點選右方的 [安裝] 按鈕,將這個套件安裝到 Xamarin.Forms 專案內
修正因為安裝 Xamarin.Essentials 帶來的錯誤
現在,可以從 Visual Studio 2019 的錯誤視窗中,看到底下的錯誤訊息
NU1107 偵測到 Xamarin.Android.Support.Compat 有版本衝突。請將 Xamarin.Android.Support.Compat 28.0.0.1 直接安裝/參考到專案 FileAccess.Android 來解決此問題。
FileAccess.Android -> FileAccess -> Xamarin.Essentials 1.1.0 -> Xamarin.Android.Support.Compat (>= 28.0.0.1)
FileAccess.Android -> Xamarin.Android.Support.Design 27.0.2.1 -> Xamarin.Android.Support.Compat (= 27.0.2.1). FileAccess.Android D:\Vulcan\GitHub\Xamarin2019\FileAccess\FileAccess\FileAccess.Android\FileAccess.Android.csproj 1
想要解決此一問題:
- 使用滑鼠右擊方案節點,選擇 [管理方案的 NuGet 套件]
- 點選 [更新] 標籤頁次
- 勾選該標籤頁次內的所有項目
- 點選右上方的更新按鈕,就可以升級這些套件到最新版本了
建立資料存取模型
- 滑鼠右擊 Xamarin.Forms 專案,選擇 [加入] > [新增資料夾]
- 將新增資料夾的名稱設定為 [DataModels]
- 滑鼠右擊 [DataModels] 資料夾,選擇 [加入] > [類別]
- 在 [新增項目] 對話窗下方的 [名稱] 欄位中,輸入 [UserInfo]
- 點選右下方的 [新增] 按鈕
- 將底下程式碼填入到這個新建立的類別檔案內
using System;
using System.Collections.Generic;
using System.Text;
namespace FileAccess.DataModels
{
public class UserInfo
{
public string Account { get; set; }
public string Password { get; set; }
}
}
修正 View 與 ViewModel
- 在 Xamarin.Forms 專案內的 [Views] 資料夾內,找到 MainPage.xaml 檔案,並且打開它
- 使用底下 XAML 語言替換掉這個檔案內的 XAML 內容
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="FileAccess.Views.MainPage"
Title="檔案存取之開發">
<StackLayout HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand"
Margin="20">
<Label Text="帳號" />
<Entry Text="{Binding Account}" Placeholder="請輸入帳號"/>
<Label Text="密碼" />
<Entry Text="{Binding Password}" Placeholder="請輸入密碼"/>
<Label Text="檔案路徑" />
<Button Text="清空輸入" Command="{Binding CleanCommand}"/>
<Label Text="{Binding FilePath}" FontSize="14" />
<StackLayout Orientation="Horizontal">
<Button Text="同步讀取" Command="{Binding SyncFileReadCommand}"/>
<Button Text="同步寫入" Command="{Binding SyncFileWriteCommand}"/>
</StackLayout>
<StackLayout Orientation="Horizontal">
<Button Text="簡易非同步讀取" Command="{Binding AsyncSimpleFileReadCommand}"/>
<Button Text="簡易非同步寫入" Command="{Binding AsyncSimpleFileWriteCommand}"/>
</StackLayout>
<StackLayout Orientation="Horizontal">
<Button Text="非同步方法讀取" Command="{Binding AsyncFileReadCommand}"/>
<Button Text="非同步方法寫入" Command="{Binding AsyncFileWriteCommand}"/>
</StackLayout>
</StackLayout>
</ContentPage>
- 在 Xamarin.Forms 專案內的 [ViewModels] 資料夾內,找到 MainPageViewModel.cs 檔案,並且打開它
- 使用底下 C# 敘述替換掉這個檔案內的 C# 敘述
using Prism.Commands;
using Prism.Mvvm;
using Prism.Navigation;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace FileAccess.ViewModels
{
using System.ComponentModel;
using System.IO;
using System.Threading.Tasks;
using FileAccess.DataModels;
using Newtonsoft.Json;
using Prism.Events;
using Prism.Navigation;
using Prism.Services;
using Xamarin.Essentials;
public class MainPageViewModel : INotifyPropertyChanged, INavigationAware
{
public event PropertyChangedEventHandler PropertyChanged;
public string Account { get; set; }
public string Password { get; set; }
public string FilePath { get; set; }
public DelegateCommand CleanCommand { get; set; }
public DelegateCommand SyncFileReadCommand { get; set; }
public DelegateCommand SyncFileWriteCommand { get; set; }
public DelegateCommand AsyncSimpleFileReadCommand { get; set; }
public DelegateCommand AsyncSimpleFileWriteCommand { get; set; }
public DelegateCommand AsyncFileReadCommand { get; set; }
public DelegateCommand AsyncFileWriteCommand { get; set; }
string filename = "User.txt";
private readonly INavigationService navigationService;
public MainPageViewModel(INavigationService navigationService)
{
this.navigationService = navigationService;
CleanCommand = new DelegateCommand(() =>
{
Account = ""; Password = "";
});
SyncFileReadCommand = new DelegateCommand(() =>
{
string path = Path.Combine(FileSystem.AppDataDirectory, "Datas");
if (Directory.Exists(path) == false) Directory.CreateDirectory(path);
FilePath = Path.Combine(path, $"Sync{filename}");
if (File.Exists(FilePath))
{
var content = File.ReadAllText(FilePath);
var userInfo = JsonConvert.DeserializeObject<UserInfo>(content);
Account = userInfo.Account;
Password = userInfo.Password;
}
});
SyncFileWriteCommand = new DelegateCommand(() =>
{
string path = Path.Combine(FileSystem.AppDataDirectory, "Datas");
if (Directory.Exists(path) == false) Directory.CreateDirectory(path);
FilePath = Path.Combine(path, $"Sync{filename}");
var userInfo = new UserInfo()
{
Account = Account,
Password = Password,
};
var content = JsonConvert.SerializeObject(userInfo);
File.WriteAllText(FilePath, content);
});
AsyncSimpleFileReadCommand = new DelegateCommand(async () =>
{
string path = Path.Combine(FileSystem.AppDataDirectory, "Datas");
if (Directory.Exists(path) == false) Directory.CreateDirectory(path);
FilePath = Path.Combine(path, $"AsyncSimple{filename}");
if (File.Exists(FilePath))
{
var content = await Task.Run(() =>
{
return File.ReadAllText(FilePath);
});
var userInfo = JsonConvert.DeserializeObject<UserInfo>(content);
Account = userInfo.Account;
Password = userInfo.Password;
}
});
AsyncSimpleFileWriteCommand = new DelegateCommand(async () =>
{
string path = Path.Combine(FileSystem.AppDataDirectory, "Datas");
if (Directory.Exists(path) == false) Directory.CreateDirectory(path);
FilePath = Path.Combine(path, $"AsyncSimple{filename}");
var userInfo = new UserInfo()
{
Account = Account,
Password = Password,
};
var content = JsonConvert.SerializeObject(userInfo);
await Task.Run(() =>
{
File.WriteAllText(FilePath, content);
});
});
AsyncFileReadCommand = new DelegateCommand(async () =>
{
string path = Path.Combine(FileSystem.AppDataDirectory, "Datas");
if (Directory.Exists(path) == false) Directory.CreateDirectory(path);
FilePath = Path.Combine(path, $"Async{filename}");
if (File.Exists(FilePath))
{
using (var fileStream = File.Open(FilePath, FileMode.Open))
{
using (var streamReader = new StreamReader(fileStream, Encoding.UTF8))
{
var content = await streamReader.ReadToEndAsync();
var userInfo = JsonConvert.DeserializeObject<UserInfo>(content);
Account = userInfo.Account;
Password = userInfo.Password;
}
}
}
});
AsyncFileWriteCommand = new DelegateCommand(async () =>
{
string path = Path.Combine(FileSystem.AppDataDirectory, "Datas");
if (Directory.Exists(path) == false) Directory.CreateDirectory(path);
FilePath = Path.Combine(path, $"Async{filename}");
using (var fileStream = File.Create(FilePath))
{
using (var streamWriter = new StreamWriter(fileStream, Encoding.UTF8))
{
var userInfo = new UserInfo()
{
Account = Account,
Password = Password,
};
var content = JsonConvert.SerializeObject(userInfo);
await streamWriter.WriteAsync(content);
}
}
});
}
public void OnNavigatedFrom(INavigationParameters parameters)
{
}
public void OnNavigatedTo(INavigationParameters parameters)
{
}
public void OnNavigatingTo(INavigationParameters parameters)
{
}
}
}
同步檔案讀寫的程式碼用法
當要寫入 .NET 物件到檔案內,在這裡範例中,將會把使用者輸入的帳號與密碼寫入到 UserInfo 類別物件內,接著使用
JsonConvert.SerializeObject(userInfo)
敘述將這個 .NET 物件轉換成為 JSON 字串表示內容。
然後透過 Xamarin.Essentinals 的 檔案系統協助程式 所提供的 API,取得該應用程式專屬的檔案存取沙箱目錄,這裡使用 FileSystem.AppDataDirectory 來取得,在這個沙箱目錄下所建立的檔案,僅僅提供該應用程式來存取,其他的應用程式、甚至該手機的使用者,無法看到與讀寫這些存在於沙箱目錄內的檔案。
在 Android 平台下,這個沙箱目錄會類似這樣的字串:/data/user/0/com.companyname.appname/files/Datas/SyncUser.txt
在 iOS 平台下,這個沙箱目錄會類似這樣的字串:/Users/vulcan/Library/Developer/CoreSimulator/Devices/EED56A19-C96E-4AF5-A5FA-83E07AB7E2A3/data/Containers/Data/Application/177E5C36-AC6F-450E-97EF-DA1AF562D29F/Library/Datas/SyncUser.txt
有了一個檔案系統下的目錄,接著便可以使用 System.IO 命名空間所提供的 File.WriteAllText API 來將剛剛產生的 JSON 字串寫入到指定的目錄下,而想要讀取特定檔案,可以使用 File.ReadAllText API 來讀取該檔案內的所有字串內容,再取得這些內容之後,便可以使用 JsonConvert.DeserializeObject(content) 這個敘述,將原先的 JSON 內容,還原成為 .NET 物件。
不過,在 .NET Standard 2.0 下,對於 System.IO 命名空間下的 File.WriteAllText / File.ReadAllText 這兩個 API,僅提供了同步呼叫的使用方式,若想要使用非同步方式來讀寫文字檔案內容,可以參考底下兩種做法;至於為什麼要使用非同的方式來進行檔案的讀寫工作呢?這一切都是要保持該 Xamarin.Forms 應用程式的 GUI 以最佳流暢的狀態下來執行。
簡單將同步檔案讀寫的程式碼轉換成為 Task 物件之用法
首先,最簡單的做法就是把這些同步運作的程式碼,指定到 Task.Run(() =>{}) 這個靜態方法內的委派方法內,因此,就會得到一個 Task 的物件,代表一個非同步的工作。現在,可以在這個方法內使用 await 關鍵字來等候剛剛取得的 工作 Task 類別物件,不過,要再方法函式內使用 await 關鍵字,需要在該方法回傳型別前,加入 async 這個修飾詞,並且該函式的迴船型別僅能為 Task, Task, void 這三種而已,若指定了其他型別,會造成編譯時期的錯誤。
非同步檔案讀寫的程式碼用法
當然 System.IO 命名空間內還提供了其他的關於檔案存取的非同步 API,在此,可以透過 File.Creeate 方法建立一個 FileStream 的物件,準備針對這個產生的檔案進行寫入的動作;有了這個 FileStream 物件,接著使用 StreamWriter 類別,使用剛剛的 FileStream 物件來建立起一個 StreamWriter 物件,如此,便可以使用 await streamWriter.WriteAsync 這樣的敘述來將文字內容,以非同步呼叫方式來寫入到檔案內。
反之,若要使用非同步方式讀取出檔案內的內容,可以使用 File.Open API,開啟指定的檔案(當然,最好還是事先檢查一下該檔案是否存在於該裝置的檔案系統內),便可以得到一個 FileStream 的物件,然後使用該物件來建立起一個 StreamReader 物件,如此,便可以使用 await streamReader.ReadToEndAsync() 這樣的非同步程式碼寫法,將檔案內的字串讀取出來,接著,使用 JsonConvert.DeserializeObject() 方法,把剛剛讀取出來的字串,還原成為 .NET 中的物件。
建置與執行和測試結果
現在,可以分別在不同的行動平台下來執行這個專案,只要將內容寫入到應用程式沙箱目錄下的檔案,除非該應用程式移除後又重新安裝起來,否則,該檔案會持續存在於這個裝置內,就算該應用程式進行升級動作,這些檔案也同樣的會持續存在。因此,可以嘗試將已經啟動的 App,強制進行關閉,讓這個 App 不再存在於裝置記憶體中,接著重新再度啟動,將會發現到,還是可以看到剛剛寫入的檔案內容。
在 Android 上進行測試
- 請設定預設起始專案為 Android 的專案
- 指定要在哪個模擬器或者實體裝置下來執行這個專案
- 在工具列上點選率色三角形按鈕,執行這個 Android 專案
- 底下是在 Android 平台下執行結果
在 iOS 上進行測試
- 請設定預設起始專案為 iOS 的專案
- 在工具列視窗中,在平台方案之下拉選單中,選擇 [iPhoneSimulator] 這個選項
- 在平台方案右方綠色三角形之啟動按鈕,點選該按鈕右方的下拉選單的黑色三角形符號,現在可以看到該 Mac 電腦上所有可用的模擬器,在此選擇 [iPhone 8 iOS 12.2] 這個選項
- 現在可以直接點選剛剛的綠色按鈕,啟動這個專案在 iOS 模擬器上來執行
- 底下是在 iOS 平台下執行結果