UWP - 同一個 App 顯示多個視窗

爲了讓用戶在 Desktop 上操作 UWP app 有更好的生產力,可以利用 Multi-Windows 的技術,讓 App 操作上更接近 Win32 程式的體驗。本篇介紹怎麽使用。

Multiple View/Windows 最典型的例子就是官方文件的這張圖:

Wireframe showing an app with multiple windows

幾個瞭解使用 Multiple views/windows 前要注意:

  1. UWP 處理 Multiple views/windows 時與 WPF/Win32 程式不一樣的地方:所有 application views 使用各自的 threads
  2. 每一個 Windows 有自己的 task bar,用戶可以在同時操作多個 windows
  3. App 支援把原本的 View 獨立成一個 Window, 也要支援可以合併回到 Main App 之中

什麽狀況適合使用 multiple views?

  1. email app, 讓用戶可以同時讀取多封内容,或是獨立 window 撰寫不需要打斷邊讀邊寫的狀況
  2. contact app, 讓用戶可以開多個 contact info 做比對
  3. music player app, 讓邊聽歌也可以邊看其他可以播放的音樂資訊
  4. note-taking app 讓用戶可以複製内容做備注是用
  5. 閲讀 App 讓用戶可以邊閲讀,邊打開作者其他作品或是相關内容,做筆記等

另外,在 Windows 10 (1803) 開始支援 multiple-instance,讓 App 可以一次開多個,這樣能做的事情更多了。

 

接著根據官方説明,定義一下 View 的概念:

  1. app view 代表一個 thread 對應一個 window,app 使用它來顯示。代表的是一個 Windows.ApplicationModel.Core.CoreApplicationView
  2. View 被 CoreApplication 管理,可利用 CoreApplication.CreateNewView 建立新的 CoreApplicationView
  3. CoreApplicationViewCoreWindowCoreDispatcher 組成,可以識別為 Windows Runtime 用來與 Windows System 互動的元件
  4. 通常不直接使用 CoreApplicationView,在 Windows Runtime 提供 Windows.UI.ViewManagement 裡的 ApplicationView 來操作
  5. ApplicationView 提供許多屬性,方法與事件,讓我們方便操作 windowing system
  6. 利用 ApplicationView.GetForCurrentWindow 拿到 ApplicationView 實體,而它與 CoreApplicationView 的 thread 是綁在一起的
  7. XAML framework 包裝 CoreWindow 物件在 Windows.UI.XAML.Window 物件,而在 XAML 操作用 Window 物件操作 CoreWindow

顯示一個新的 View 建議在在明顯的位置放入一個 new window 的按鈕,幫助使用者知道這個 view 可以分割到另一個新 window。

或者是在 context menu 加入 Open in a new window

透過下面的範例程式簡單説明,如何建立一個新的 view:

private async void Button_Click(object sender, RoutedEventArgs e)
{
    CoreApplicationView newView = CoreApplication.CreateNewView();
    int newViewId = 0;
    await newView.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
    {
        Frame frame = new Frame();
        frame.Navigate(typeof(SecondaryPage), null);   
        Window.Current.Content = frame;
        // You have to activate the window in order to show it later.
        Window.Current.Activate();

        newViewId = ApplicationView.GetForCurrentView().Id;
    });
    bool viewShown = await ApplicationViewSwitcher.TryShowAsStandaloneAsync(newViewId);
}

上述程式碼中幾個重點:

  • ApplicationView.GetForCurrentView 可以抓出該 thread 中 view 的 Id,搭配 ApplicationViewSwitcher 要切換到哪一個 view 上。
    可以參考 MultipleViews Sample 建立的 ViewLifetimeControl 可以管理被建立出來的 views (非常建議使用)。
    補充 ViewLifetimeControl 的重點:
    1. new view 的 page 記得註冊 ViewLifetimeControl 的 Released 事件,幫助關閉 secondary view 時做一些處理,範例:
      private async void ViewLifetimeControl_Released(Object sender, EventArgs e)
      {
          ((ViewLifetimeControl)sender).Released -= ViewLifetimeControl_Released;
          // The ViewLifetimeControl object is bound to UI elements on the main thread
          // So, the object must be removed from that thread
          await mainDispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
          {
              ((App)App.Current).SecondaryViews.Remove(thisViewControl);
          });
      
          // The released event is fired on the thread of the window
          // it pertains to.
          //
          // It's important to make sure no work is scheduled on this thread
          // after it starts to close (no data binding changes, no changes to
          // XAML, creating new objects in destructors, etc.) since
          // that will throw exceptions
          Window.Current.Close();
      }
  • CoreDispatcher.RunAsync 建立工作排程,用 lambda expression 寫的内容會在 new view 所屬的 thread 中被執行。
  • 在 new View 中設定好 Window 之後,要記得使用 Window.Current.Activate(); 該 View 才會被啓動
  • 可利用 ApplicationViewSwitcher.TryShowAsStandaloneAsync 要求顯示特定的 View。
    另外可以使用 ApplicationView.GetApplicationViewIdForWindow 來抓取目前 Window 的 View Id

上述的程式範例中, Main View 與 Secondary View 的定義其實有些模糊,根據官方文件的介紹,透過下面説明:

Main View

  • 當 App 被啓動時預設會建立一個 main view (第一個 view),它被保存在 CoreApplication.MainView 裏面,它的 IsMain = true
  • main view 的 thread 負責該 App 上面的所有事件與畫面控制。
  • 如果 secondary view 被開啓,main view 的 window 會被隱藏,例如:按下 close (X) 在 window title bar ,但它的 thread 還活著,在 main view 的 window 呼叫 close() 會得到 InvaildOperationException。
  • 可利用 Application.Exit 關閉 App。如果 main view 的 thread 被結束,代表 app 被關閉。

Secondary views

  1. 透過 CoreApplication.CreateNewView() 建立的 view 都算是 secondary views。main view 與 secondary views 被保存在 CoreApplication.Views 集合裡。
  2. 通常會建立 secondary views 都來自使用者自行點擊,部分會來自系統的需求(例如:使用 kiosk mode,系統會自動建立 secondary view 在 lock screen 上顯示。
    在使用 Kiosk mode 時不支援自己建立 secondary view,如果建立了會造成 exception)

最後介紹幾個重要的元素:

  • ApplicationViewSwitcher Class
    負責 app view 交換的行爲。常用的 methods:
    Method Name Description
    TryShowAsStandaloneAsync 在螢幕上為 App 顯示與原始視窗相鄰的另一個視窗。 該 method 只能在 ASTA(core UI) thread 中使用。
    每一個新建立的 view 都有自己的 UI thread(ASTA) 與相關的 CoreWindow。 要注意使用 thread-safe (例如:CoreDispatcher) 讓 window 之間可以互動溝通。
    TryShowAsStandaloneAsync(Int32, ViewSizePreference) 在螢幕上為 App 顯示指定另一個特定 ViewSizePreference 的視窗。
  • ViewSizePreference
    定義 window 可能的顯示 size。
    Custom 6 window 使用自定義 size 來顯示
    Default 0 window 不指定 size,改用預設(UseHalf)
    IseHalf 2 window 使用 50% 的可視水平畫面為 size
    UseLess/td> 1 window 使用低於 50% 的可視水平畫面為 size
    UseMinimum 4 window 使用最小可視水平畫面(320 或 500 pixels)為 size
    UseMore 3 window 使用高於 50% 的可視水平畫面為 size
    UseNone 5 window 沒有可見元件
  • CoreApplication
    使應用程式能夠處理狀態更改、管理 windows 以及與各種 UI 框架組成。
    系統在運行應用程式時將此物件作為單一實例創建。
    它被當作 Application Single Threaded Apartment (ASTA) 來運行。
    而從 Singleton 建立的 threads 應歸因於多執行緒單元 (MTAThread)。
    Type Name Description
    Properties MainView 取得使用此 CoreApplication 實例化的所有正在運行的 CoreApplicationView
      Views 取得 app 所有的 views
    Methods CreateNewView() 為 App建立新的 view
      Exit() 關閉 App
      GetCurrentView() 設定 view 被啓用
      RequestRestartAsync(String) 重新啓動 App
  • ApplicationView
    代表活動中的 application view 與相關的狀態/行爲。 window(或稱 app view) 是 Windows Runtime app 的顯示部分。
    使用者螢幕可以同時顯示多達4個可變寬度視窗。它們不重疊, 其頂部和底部邊緣觸及螢幕的頂部和底部邊緣。相鄰視窗之間可能存在非視窗區域。
    window 與 page 不一樣,它比較像是 pages 的容器。可以在程式中對應用程式的所有頁使用視窗引用。
    每一個 window 對應一個 CoreWindow,它代表 UI process thread (core input handlers 與 event dispatcher)。
    Type Name Description
    Properties AdjacentToLeftDisplayEdge 告訴您螢幕的左邊緣是否為視窗的左邊框
      IsFullScreenMode 取得/設定 App 是否為 full-screen 模式
      PreferredLaunchViewSize 設定或取得當 app 被啓動時預期的 size, 需要搭配 PreferredLaunchWindowingMode 屬性一起使用(設定為 PreferredLaunchViewSize)
      PreferredLaunchWindowingMode 設定或取得 app 啓動時視窗模式的值, 搭配 PreferredLaunchViewSize 一起使用。 可以設定的值有:
    • Auto:系統會自動調整應用程式視窗的大小。
    • FullScreen:視窗是全屏幕。
    • PreferredLaunchViewSize:視窗的大小由 ApplicationView. PreferredLaunchViewSize 屬性指定。
    Methods GetApplicationViewIdForWindow(ICoreWindow) 利用 CoreWindow 取得他的 window ID
      GetForCurrentView() 取得啓動程式的 view state 與 behavior settings
      TryConsolidateAsync() 嘗試關閉現在的 view。這個 method 等同於用戶在 app view 點了 close。
      TryResizeView(Size) 嘗試調整顯示的 size。
    Events Consolidated 發生在 window 被從最近使用的程式清單中移除,或是用戶執行關閉筆勢時發生。
      VisibleBoundsChanged 當 VisibleBounds 的值發生更改時, 將引發此事件, 通常是顯示或隱藏的狀態列、應用程式欄或其他 chrome 的結果。

[補充]

  • 如果希望記錄每一個 secondary views 最後的 size,讓下一次開啓的時候可以恢復原本的 window size,要怎麽做呢?
    關鍵元素:ApplicationView.PreferredLaunchWindowingMode
    1. 搭配 Multiple views sample 的 SecondaryViewPage 爲例,加入以下的 Code:
      // 記錄 SecondaryViewPage 所屬的 View Id
      int secondaryViewId;
      
      protected override void OnNavigatedTo(NavigationEventArgs e)
      {
          thisViewControl = (ViewLifetimeControl)e.Parameter;
          mainViewId = ((App)App.Current).MainViewId;
          mainDispatcher = ((App)App.Current).MainDispatcher;
      
          // When this view is finally release, clean up state
          thisViewControl.Released += ViewLifetimeControl_Released;
      
          // 檢查是否為非 MainViewId, 避免設定錯誤
          secondaryViewId = ApplicationView.GetForCurrentView().Id;
      
          if (mainViewId != secondaryViewId)
          {
              // 重點: 把 PreferredLaunchWindowingMode 設定為 PreferredLaunchViewSize
              // 讓 SecondaryViewPage 在出現時變成自定義視窗大小, TryResizeView 才會成功
              ApplicationView.PreferredLaunchWindowingMode = ApplicationViewWindowingMode.PreferredLaunchViewSize;
      
              // 註冊事件處理第一次出現該 View 時要調整視窗大小
              Window.Current.VisibilityChanged += Window_VisibilityChanged;
          }
      }
    2. 建立一個 AppSettings 儲存 SecondaryViewPage 調整的 Size:
      public class AppSettings
      {
          private ApplicationDataContainer localSettings = ApplicationData.Current.LocalSettings;
      
          public void Set(string key, T value)
          {
              if (localSettings.Values.ContainsKey(key))
              {
                  localSettings.Values[key] = value;
              }
              else
              {
                  localSettings.Values.Add(key, value);
              }
          }
      
          public T Get(string key)
          {
              if (localSettings.Values.ContainsKey(key))
              {
                  return (T)localSettings.Values[key];
              }
              else
              {
                  return default(T);
              }
          }
      }
    3. 在 VisibilityChanged 時處理第一次出現視窗時把視窗大小設定為上一次調整後的結果:
      // 用來識別是否已經設定過初始的視窗大小
      bool isInitialResize = false;
      
      private void Window_VisibilityChanged(object sender, VisibilityChangedEventArgs e)
      {
          if (e.Visible && isInitialResize == false)
          {
              // 檢查是否為 SecondaryViewPage 所屬的 View Id
              if (secondaryViewId == ApplicationView.GetApplicationViewIdForWindow(Window.Current.CoreWindow))
              {
                  // 從 App Setting 中得到上次調整大小的結果
                  var defaultSize = settings.Get(SECONDARY_DEFAULT_SIZE);
      			
                  if (defaultSize == null)
                  {
                      defaultSize = new Size(200,100);
                  }
      			
                  // 設定 ApplicationView 的大小,要記得設定 ApplicationViewWindowingMode.PreferredLaunchViewSize ,不然都會是 false
                  bool result = ApplicationView.GetForCurrentView().TryResizeView(defaultSize);
                  isInitialResize = true;
      			
                  // 註冊處理 Size Changed 來保存視窗最後的大小
                  Window.Current.SizeChanged += Current_SizeChanged;			
              }
          }
          else
          {
              Window.Current.SizeChanged -= Current_SizeChanged;
          }
      }
    4. 處理 SizeChanged 把調整大小的結果儲存在 AppSettings
       private void Current_SizeChanged(object sender, WindowSizeChangedEventArgs e)
      {
          // 要注意是 ActivatedInForeground 才需要記錄
          if (Window.Current.CoreWindow.ActivationMode != CoreWindowActivationMode.ActivatedInForeground)
          {
              return;
          }
      
          settings.Set(SECONDARY_DEFAULT_SIZE, e.Size);
      }
  • 可在畫面中加入一個 open new window 的 glyph,乾净且清楚告訴用戶怎麽使用。例如:
    // C# code
    Button btnIcon = new Button();
    btnIcon.FontFamily = new FontFamily("Segoe MDL2 Assets");
    btnIcon.Content = "\uE8A7";
    
    // XAML
    <Button x:Name="btn" Content="&#xE8A7;" FontFamily="Segoe MDL2 Assets" />
  • 需要確認 single view 時所有功能都能正常使用,分割 secondary views 只是方便使用,不會佔用所有的功能
  • 不要依賴 secondary view 去提供通知或顯示效果

======

UWP 發展到現在對於 Desktop 的支援度更完整,讓我們在開發的時候可以降低原本習慣 Win32 操作的門檻。

希望這篇對大家有所幫助,謝謝。

References