2017-10-07

Xamarin.Forms - 從網路下載檔案,並且儲存到手機中,接著,可以使用安裝在手機內的應用程式,開啟這個檔案

這是一份 Xamarin.Forms 高等進階開發技術筆記,我們在這裡將會針對如何設計一個 Xamarin.Forms 應用專案,可以下載網路上的圖片、音樂、影片、文件檔案(pdf, ppt, doc, xls等等)到手機上,並且使用手機上預設安裝的 App,來開啟這些已經下載的檔案。
想要完成這樣的需求程式設計,您需要了解如何在 Xamarin.Forms 使用底下的技術:
  • MVVM開發框架,在這裡,我們使用 Prism 作為我們開發與設計 MVVM 架構的框架
  • 安裝與使用 HttpClient 來下載網路資源
  • 使用 using 來操作 IDisposable 實作類別物件
  • 使用 PCLStorage 套件,將剛剛下載的網路資源,儲存到本機裝置記憶卡內
  • 可以指定儲存檔案的地方,不是該應用程式的沙箱,而是可以由使用者自行存取的目錄下。
  • 使用相依性服務注入技巧,注入可以開啟該檔案的物件與使用其相關方法

測試頁面

在這裡,我們設計一個頁面,在這個頁面上,僅有兩個控制項,一個是 Picker 另外一個是 Button;使用者可以透過 Picker 選擇要下載檔案的類型,接著按下按鈕,就會開始進行下載該檔案與開啟該檔案。
底下是這個測試頁面的 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"
             xmlns:prism="clr-namespace:Prism.Mvvm;assembly=Prism.Forms"
             xmlns:behavior="clr-namespace:Prism.Behaviors;assembly=Prism.Forms"
             prism:ViewModelLocator.AutowireViewModel="True"
             x:Class="XFFileDownload.Views.MainPage"
             Title="MainPage">
    <Grid
        BackgroundColor="White">
        <StackLayout
            HorizontalOptions="Center" VerticalOptions="Center">
            <Picker
            ItemsSource="{Binding FileSourceTypes}"
            SelectedItem="{Binding FileSourceTypeSelect}"
            />
            <Button Text="下載檔案"
                Command="{Binding DownloadCommand}"/>
        </StackLayout>

        <BoxView
            Color="Gray"
            IsVisible="{Binding ShowMask}"
            />
    </Grid>
</ContentPage>
接下來,我們來看看這個頁面 ViewModel 的程式碼
首先,我們在 OnNavigatedTo 事件方法中,進行 Picker 下拉選單資料項目的初始化。
在建構函式內 public MainPageViewModel(INavigationService navigationService, IPublicFileSystem publicFileSystem, IOpenFileByName openFileByName) ,我們將會把我們等下要設計的兩個介面 IPublicFileSystem 與 IOpenFileByName,在這使用建構函式注入物件的方式,將這兩個介面的原生專案中所實作出來並且產生的物件,注入到這個頁面 ViewModel內,讓相關程式碼可以進行相關操作。
由於 PCLStorage 套件所提供的 FileSystem 類別,僅會提供該應用程式沙箱可以使用到的資料夾,因此,我們將會透過 _PublicFileSystem.PublicDownloadFolder 來取得指向該原生平台下的可以公開存取的 IFolder 資料夾。
我們使用 IFile 物件, file ,建立一個檔案,並且使用 file.OpenAsync(FileAccess.ReadAndWrite) 取得可以讀寫的 stream 物件。
此時,我們將會建立 HttpClient 物件,client,使用 client.GetStreamAsync(url) 方法,取得這個網路資源的 stream 物件,並且使用敘述 fooStream.CopyTo(fooFileStream); 將從網路取得的檔案內容,複製到本機檔案中;若您取得網路檔案的方法需要使用 Post 的方式來取得,您也是使用同樣的方式,取得 HttpResponseMessage.Content.ReadAsStreamAsync 的 stream 物件,就可以進行寫入到本機儲存空間內。
在上述的檔案與目錄操作的程式碼,我們都是使用 PCLStorage 套件所提供的類別與相關方法。
最後,我們使用 _OpenFileByName.OpenFile(file.Path); 將這個檔案,使用安裝在手機中的應用程式來開啟。
    public class MainPageViewModel : INotifyPropertyChanged, INavigationAware
    {
        public event PropertyChangedEventHandler PropertyChanged;

        private readonly INavigationService _navigationService;


        public ObservableCollection<string> FileSourceTypes { get; set; } = new ObservableCollection<string>();
        public string FileSourceTypeSelect { get; set; }
        public bool ShowMask { get; set; } = false;
        public DelegateCommand DownloadCommand { get; set; }

        IPublicFileSystem _PublicFileSystem;
        IOpenFileByName _OpenFileByName;
        public MainPageViewModel(INavigationService navigationService,
            IPublicFileSystem publicFileSystem, IOpenFileByName openFileByName)
        {
            _navigationService = navigationService;
            // 注入各平台的非應用程式專屬的沙箱資料夾
            _PublicFileSystem = publicFileSystem;
            // 使用手機內安裝的App,開啟指定的檔案
            _OpenFileByName = openFileByName;

            DownloadCommand = new DelegateCommand(async () =>
            {
                ShowMask = true;
                #region 依據所選擇的項目,設定下載來源與檔案名稱
                string filename = "";
                string url = "";
                if (FileSourceTypeSelect.ToLower() == "pdf")
                {
                    filename = "vulcan.pdf";
                    url = "https://www.tutorialspoint.com/csharp/csharp_tutorial.pdf";
                }
                else if (FileSourceTypeSelect.ToLower() == "image")
                {
                    filename = "vulcan.png";
                    url = "https://pluralsight.imgix.net/paths/path-icons/csharp-e7b8fcd4ce.png";
                }
                else if (FileSourceTypeSelect.ToLower() == "mp3")
                {
                    filename = "vulcan.mp3";
                    url = "http://video.ch9.ms/ch9/4855/ca67b144-e675-48a2-a0f2-706af9644855/DataTemplateSelector.mp3";
                }
                else if (FileSourceTypeSelect.ToLower() == "video")
                {
                    filename = "vulcan.mp4";
                    url = "http://video.ch9.ms/ch9/4855/ca67b144-e675-48a2-a0f2-706af9644855/DataTemplateSelector.mp4";
                }
                else if (FileSourceTypeSelect.ToLower() == "ppt")
                {
                    filename = "vulcan.ppt";
                    url = "http://people.csail.mit.edu/mrub/talks/small_world/Seminar07_rubinstein.ppt";
                }
                else if (FileSourceTypeSelect.ToLower() == "doc")
                {
                    filename = "vulcan.doc";
                    url = "http://im2.nhu.edu.tw/download.php?filename=270_2af7568a.doc&dir=personal_subject/&title=C%23-%E7%AC%AC%E4%B8%80%E7%AB%A0";
                }
                #endregion

                // 取得要存放該檔案的資料夾
                // FileSystem 為 PCLStorage 提供的應用程式沙箱的相關資料夾
                IFolder rootFolder = _PublicFileSystem.PublicDownloadFolder;
                try
                {
                    // 建立這個檔案
                    IFile file = await rootFolder.CreateFileAsync(filename,
                        CreationCollisionOption.OpenIfExists);
                    // 取得這個檔案的 Stream 物件
                    using (var fooFileStream = await file.OpenAsync(FileAccess.ReadAndWrite))
                    {
                        using (HttpClientHandler handle = new HttpClientHandler())
                        {
                            // 建立 HttpClient 物件
                            using (HttpClient client = new HttpClient(handle))
                            {
                                // 取得指定 URL 的 Stream
                                using (var fooStream = await client.GetStreamAsync(url))
                                {
                                    // 將網路的檔案 Stream 複製到本機檔案上
                                    fooStream.CopyTo(fooFileStream);
                                }
                            }
                        }
                    }

                    _OpenFileByName.OpenFile(file.Path);
                }
                catch (Exception ex)
                {
                    Debug.WriteLine(ex.Message);
                }
                ShowMask = false;
            });
        }

        public void OnNavigatedFrom(NavigationParameters parameters)
        {

        }

        public void OnNavigatingTo(NavigationParameters parameters)
        {

        }

        public void OnNavigatedTo(NavigationParameters parameters)
        {
            FileSourceTypes.Clear();
            FileSourceTypes.Add("PDF");
            FileSourceTypes.Add("Image");
            FileSourceTypes.Add("MP3");
            FileSourceTypes.Add("Video");
            FileSourceTypes.Add("PPT");
            FileSourceTypes.Add("Doc");

        }

    }

取得公開資料夾的介面與實作

首先,我們在 PCL 專案內,建立 IPublicFileSystem 這個介面,這個介面內僅有四個屬性需要在原生平台內實作出來。
    public interface IPublicFileSystem
    {
        IFolder PublicDownloadFolder { get; }
        IFolder PublicPictureFolder { get; }
        IFolder PublicMovieFolder { get; }
        IFolder PublicDCIMFolder { get; }
    }
在 Android 平台下,我們實作 IPublicFileSystem 介面,並且記得要使用 [assembly: Xamarin.Forms.Dependency(typeof(PublicFileSystem))] 來宣告這個介面實作,可以用於相依性服務注入之用。
我們在這個介面實作中,取得個公開資料夾的絕對路徑,並且建立 PCLStorage 套件的 FileSystemFolder類別物件,這就是我們要使用的 IFolder 實作物件。
有關 Android 平台下關於這些資料夾的說明,請參考 https://developer.android.com/reference/android/os/Environment.html#DIRECTORY_DOWNLOADS
[assembly: Xamarin.Forms.Dependency(typeof(PublicFileSystem))]
namespace XFFileDownload.Droid.Services
{
    class PublicFileSystem : IPublicFileSystem
    {
        public IFolder PublicDownloadFolder
        {
            get
            {
                var localAppData = Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryDownloads).AbsolutePath;
                return new FileSystemFolder(localAppData);
            }
        }
        public IFolder PublicPictureFolder
        {
            get
            {
                var localAppData = Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryPictures).AbsolutePath;
                return new FileSystemFolder(localAppData);
            }
        }
        public IFolder PublicMovieFolder
        {
            get
            {
                var localAppData = Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryMovies).AbsolutePath;
                return new FileSystemFolder(localAppData);
            }
        }
        public IFolder PublicDCIMFolder
        {
            get
            {
                var localAppData = Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryDcim).AbsolutePath;
                return new FileSystemFolder(localAppData);
            }
        }
    }
}
在 iOS 平台下,我們實作 IPublicFileSystem 介面,並且記得要使用 [assembly: Xamarin.Forms.Dependency(typeof(PublicFileSystem))] 來宣告這個介面實作,可以用於相依性服務注入之用。
我們在這個介面實作中,取得個公開資料夾的絕對路徑,並且建立 PCLStorage 套件的 FileSystemFolder類別物件,這就是我們要使用的 IFolder 實作物件。
有關 iOS 平台下關於這些資料夾的說明,請參考 https://developer.xamarin.com/guides/ios/application_fundamentals/working_with_the_file_system/
[assembly: Xamarin.Forms.Dependency(typeof(PublicFileSystem))]
namespace XFFileDownload.iOS.Services
{
    class PublicFileSystem : IPublicFileSystem
    {
        public IFolder PublicDownloadFolder
        {
            get
            {
                var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
                return new FileSystemFolder(localAppData);
            }
        }
        public IFolder PublicPictureFolder
        {
            get
            {
                var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
                return new FileSystemFolder(localAppData);
            }
        }
        public IFolder PublicMovieFolder
        {
            get
            {
                var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
                return new FileSystemFolder(localAppData);
            }
        }
        public IFolder PublicDCIMFolder
        {
            get
            {
                var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
                return new FileSystemFolder(localAppData);
            }
        }
    }
}

取得公開資料夾的介面與實作

首先,我們在 PCL 專案內,建立 IOpenFileByName 這個介面,這個介面內僅有一個方法需要在原生平台內實作出來。
    public interface IOpenFileByName
    {
        void OpenFile(string fullFileName);
    }
在 Android 平台下,我們實作 IOpenFileByName 介面,並且記得要使用 [assembly: Xamarin.Forms.Dependency(typeof(OpenFileByName))] 來宣告這個介面實作,可以用於相依性服務注入之用。
這個實作方法,首先,將這個檔案,複製到公開的資料夾內,也就是說,您下載的檔案,是可以儲存在應用程式沙箱資料夾內,也是可以正常使用手機內安裝的應用程式來開啟這個檔案的。
最後,我們使用 Intent intent = new Intent(Intent.ActionView); 來讓使用者選擇適合的應用程式,來開啟這個檔案。
[assembly: Xamarin.Forms.Dependency(typeof(OpenFileByName))]
namespace XFFileDownload.Droid.Services
{
    public class OpenFileByName : IOpenFileByName
    {
        public void OpenFile(string fullFileName)
        {
            try
            {
                var filePath = fullFileName;
                var fileName = Path.GetFileName(fullFileName);

                var bytes = File.ReadAllBytes(filePath);

                string externalStorageState = global::Android.OS.Environment.ExternalStorageState;
                var externalPath = global::Android.OS.Environment.ExternalStorageDirectory.Path + "/" + 
                    global::Android.OS.Environment.DirectoryDownloads + "/" + fileName;
                File.WriteAllBytes(externalPath, bytes);

                Java.IO.File file = new Java.IO.File(externalPath);
                file.SetReadable(true);

                string application = "";
                string extension = Path.GetExtension(filePath);

                // get mimeTye
                switch (extension.ToLower())
                {
                    case ".txt":
                        application = "text/plain";
                        break;
                    case ".doc":
                    case ".docx":
                        application = "application/msword";
                        break;
                    case ".pdf":
                        application = "application/pdf";
                        break;
                    case ".xls":
                    case ".xlsx":
                        application = "application/vnd.ms-excel";
                        break;
                    case ".jpg":
                    case ".jpeg":
                    case ".png":
                        application = "image/jpeg";
                        break;
                    default:
                        application = "*/*";
                        break;
                }

                //Android.Net.Uri uri = Android.Net.Uri.Parse("file://" + filePath);
                Android.Net.Uri uri = Android.Net.Uri.FromFile(file);
                Intent intent = new Intent(Intent.ActionView);
                intent.SetDataAndType(uri, application);
                intent.SetFlags(ActivityFlags.ClearWhenTaskReset | ActivityFlags.NewTask);

                Forms.Context.StartActivity(intent);
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
        }
    }
}
在 iOS 平台下,我們實作 IOpenFileByName 介面,並且記得要使用 [assembly: Xamarin.Forms.Dependency(typeof(OpenFileByName))] 來宣告這個介面實作,可以用於相依性服務注入之用。
我們建立一個類別 UIDocumentInteractionControllerDelegateClass,用來顯示這個檔案。
[assembly: Xamarin.Forms.Dependency(typeof(OpenFileByName))]
namespace XFFileDownload.iOS.Services
{
    public class OpenFileByName : IOpenFileByName
    {
        public void OpenFile(string fullFileName)
        {
            var filePath = fullFileName;
            var fileName = Path.GetFileName(fullFileName);

            //Device.OpenUri(new Uri(filePath));
            var PreviewController = UIDocumentInteractionController.FromUrl(NSUrl.FromFilename(filePath));
            PreviewController.Delegate = new UIDocumentInteractionControllerDelegateClass(UIApplication.SharedApplication.KeyWindow.RootViewController);
            Device.BeginInvokeOnMainThread(() =>
            {
                PreviewController.PresentPreview(true);
            });
        }
    }

    public class UIDocumentInteractionControllerDelegateClass : UIDocumentInteractionControllerDelegate
    {
        UIViewController ownerVC;

        public UIDocumentInteractionControllerDelegateClass(UIViewController vc)
        {
            ownerVC = vc;
        }

        public override UIViewController ViewControllerForPreview(UIDocumentInteractionController controller)
        {
            return ownerVC;
        }

        public override UIView ViewForPreview(UIDocumentInteractionController controller)
        {
            return ownerVC.View;
        }
    }
}

2017-10-02

如何在 Xamarin.Forms 中執行結束應用程式的功能

大家經常都在詢問這樣的問題:如何在 Xamarin.Forms 中執行結束應用程式的功能
我個人認為,這個問題,不是技術上的問題,而是問問題的人,需要對於您開發的應用程式所在的平台上,了解這個平台提供的應用程式生命週期;一旦你對於 iOS & Android 平台的應用程式生命週期有了解之後,這個問題也就解決了;喔,你們沒有聽錯,可是,我們有看到任何的技術方法呀,可否告訴我究竟要呼叫甚麼 API 才能夠結束應用程式的執行嗎?
這個答案是很明確的,在行動裝置作業系統中,iOS / Android ,想要結束您開發的應用程式執行,在各自的應用程式生命週期中,只有兩個方法,一個就是當作業系統資源不足夠的時候,作業系統有權將任何應用程式立即結束執行;另外一個就是使用者可以透過手勢或者作業系統提供的操作介面,手動的結束特定應用程式的執行。
從應用程式的生命週期,每個應用程式是沒有透過 App 結束執行這件事情,只有在前景執行或者移到背景去等候再度回到前景來執行。所以,請不要逢人就詢問 如何在 Xamarin.Forms 中執行結束應用程式的功能,這不是 Xamarin.Forms 也不是 Xamarin 可以做到的事情,原則上,只要作業系統有提供這樣的 API,你就可以在 Xamarin.Forms 呼叫他來使用。
在這篇文章中 How do I programmatically quit my iOS application?,你會看到蘋果官方的回覆:There is no API provided for gracefully terminating an iOS application.。對於不死心的人,還是想要這麼做,那麼,請自行觀看蘋果相關的上架說明文件(不要沒看文件就到處用您的智慧來想像該怎麼做),強制這麼做的話,是無法上架App到公開的 App Store 市集上。
好的,若您還是堅持要這麼做,我們來看看在 Xamarin.Form 的開發環境中,該如何做到這樣的需求,在這裡,我們設計一個按鈕,當使用者點選這個按鈕之後,就會 ViewModel 內,執行一個注入原生平台的物件,執行結束應用程式的功能。

建立一個結束應用程式的介面

首先,我們在 Xamarin.Forms 專案內,建立一個介面,這個介面裡面只有定義一個方法 Exit(),當使用者呼叫這個方法之後,就會結束這個應用程式的執行。
    public interface IAppExit
    {
        void Exit();
    }

測試頁面 View / ViewModel

我們的測試 App 很簡單,只有一個按鈕
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:prism="clr-namespace:Prism.Mvvm;assembly=Prism.Forms"
             prism:ViewModelLocator.AutowireViewModel="True"
             x:Class="XFExit.Views.MainPage"
             Title="MainPage">
    <StackLayout HorizontalOptions="Center" VerticalOptions="Center">
        <Label Text="{Binding Title}" />
        <Button
            Text="結束應用程式"
            Command="{Binding CloseAppCommand}"/>
    </StackLayout>
</ContentPage>
當按下了 結束應用程式 按鈕之後,就會執行 ViewModel 內的 CloseAppCommand DelegateCommand 物件方法。
在底下的測試範例 ViewModel 中,我們透過相依性服務注入技術,將 IAppExit 實作物件注入到 ViewModel 內,並且執行 _AppExit.Exit(); 方法之後,就會結束應用程式的執行。
    public class MainPageViewModel : INotifyPropertyChanged, INavigationAware
    {
        public event PropertyChangedEventHandler PropertyChanged;

        private readonly INavigationService _navigationService;

        public DelegateCommand CloseAppCommand { get; set; }
        IAppExit _AppExit;
        public MainPageViewModel(INavigationService navigationService, IAppExit appExit)
        {
            _navigationService = navigationService;
            _AppExit = appExit;
            CloseAppCommand = new DelegateCommand(() =>
            {
                _AppExit.Exit();
            });
        }

        public void OnNavigatedFrom(NavigationParameters parameters)
        {

        }

        public void OnNavigatingTo(NavigationParameters parameters)
        {

        }

        public void OnNavigatedTo(NavigationParameters parameters)
        {

        }

    }

Android 平台實作 IAppExit

現在,我們需要在 Android 專案內,建立一個類別 AppExit,這個類別需要實作 IAppExit 介面的方法,我們在 Exit() 這個方法中,呼叫 Android.OS.Process.KillProcess(Android.OS.Process.MyPid());,這樣,當這個 Android 平台的 Xamarin.Forms 專案執行的時候,就會結束這個應用程式的執行。
[assembly: Xamarin.Forms.Dependency(typeof(AppExit))]
namespace XFExit.Droid.Servoces
{
    class AppExit : IAppExit
    {
        public void Exit()
        {
            Android.OS.Process.KillProcess(Android.OS.Process.MyPid());
        }
    }
}

iOS 平台實作 IAppExit

現在,我們需要在 iOS 專案內,建立一個類別 AppExit,這個類別需要實作 IAppExit 介面的方法,我們在 Exit() 這個方法中,呼叫 System.Diagnostics.Process.GetCurrentProcess().CloseMainWindow();,這樣,當這個 iOS 平台的 Xamarin.Forms 專案執行的時候,就會結束這個應用程式的執行。
當然,你也可以執行 .NET 平台的中止執行續的方法:Thread.CurrentThread.Abort(); 這樣,也會結束這個應用程式的執行。
[assembly: Xamarin.Forms.Dependency(typeof(AppExit))]
namespace XFExit.iOS.Services
{
    public class AppExit : IAppExit
    {
        public void Exit()
        {
            System.Diagnostics.Process.GetCurrentProcess().CloseMainWindow();
            //Thread.CurrentThread.Abort();
        }
    }
}