UWP - Background Media Player 開發教學

<UWP - 新 BackgroundMediaPlayback 架構>介紹新的架構讓 foreground 與 background 使用 single process 來開發。 該篇介紹怎麽利用新的架構開發 Background Media Player。

介紹新的 BackgroundMediaPlayer 之前,再復習 single process model 的 App lifecycle,參考 <Background activity with the Single Process Model>。 1_lifecycle

情境 事件流程
重新開啓 OnLaunched() -> LeavingBackground()
離開 App EnteredBackground() -> Suspending()
回到 App Resume() -> LeavingBackground()

兩個特別的事件:

  • EnterBackground()

代表 App 從 Foreground 進入 Background 。該階段重點在 釋放 UI 的内容或是其他記憶體用量。 因爲 App 退到 Background 過多的記憶體用量會讓系統變慢,也容易被系統判別為需要進行 Suspended 或是 terminated 的對象。 有那些事情建議處理呢?

  • 釋放 UI 中在背景不會需要用的資源,例如:image 或是 UI Elements
  • 在 EnterBackground() 事件中不要執行太久的時間,容易被 suspended
  • 可以標記目前在 Background 狀態,搭配 AppMemoryUsageLimitChanged 降低記憶體使用率
  • LeavingBackground()

代表 App 從 Background 回到 Foreground,該階段重點在 還原 UI 的内容 ,因爲 App 到 Background 時爲了降低記憶體用量 (例如在 EnterBackground() 時做的事情要準備還原),要釋放 UI 的内容或是其他 App 裏面需要用的資源。

爲什麽需要做這些事情,因爲新的架構 (Background Activity With the Single Process Model) 讓 App 進入 Background 時任然可以運作,代表 App 任然會占用系統資源(如果使用  Background Task 它沒有 UI 就相對簡單)。

爲了保持穩定的系統記憶體與效能,App 需處理系統記憶體可用限制的變化,可以參考<Free memory when your app moves to the background>有更多的説明。 參考<Background media playback sample>的做法,注冊接受  MemoryManager AppMemoryUsageLimitChangingAppMemoryUsageIncreased 兩個事件,負責在系統通知 App 記憶體可用量限制改變的時候,判別目前 App 是否超過記憶體限制需要釋放。

提供 App 可得到使用的記憶體量與相關資訊。重點特性如下:

Type Name Description
Event AppMemoryUsageDecreased 當 App 的記憶體消耗已下降到 AppMemoryUsageLevel 的最低程度時觸發。 例如:App 使用記憶體量從 Hight 到 Low 時觸發這個事件。如果有需要記憶體時可以在時候執行其他任務。
  AppMemoryUsageIncreased 當 App 的記憶體消耗已增加至 AppMemoryUsageLevel 的較高程度時觸發。 例如:App 使用記憶體量從 Low 到 Medium 時觸發這個事件,如果 App 在背景時建議要處理降低記憶體。
  AppMemoryUsageLimitChanged 在 App 可用總記憶體限制改變前觸發。 通常在 App 從 Foreground 轉到 Background 的時候 MemoryManager 會收到系統通知。 如果 App 記憶體用量超過新的限制,要在 2 seconds 内減少記憶體用量 (尤其是 Xbox),因爲在某些設備上可能會被系統直接要求進行 suspended 或是 terminatedAppMemoryUsageLimitChangingEventArgs
  • NewLimit Gets the new limit for how much total memory the app can use, in bytes.
  • OldLimit Gets the old limit for how much total memory the app can use, in bytes.
Method GetAppMemoryReport Gets an AppMemoryReport for the app, which provides information about its memory usage.
  GetProcessMemoryReport Gets a ProcessMemoryReport for a process, which provides information about its memory usage.
  TrySetAppMemoryUsageLimit Tries to set a specific memory cap for the current app or task. In cases where memory caps are shared between foreground and background components, any difference between the default cap and the new request will be assigned to the other component.
Property AppMemoryUsage Read-only. Gets the app's current memory usage.
  AppMemoryUsageLevel Read-only. Gets the app's memory usage level.
  AppMemoryUsageLimit Read-only. Gets the app's memory usage limit.

參考<Background media playback sample>的範例,可以看到在 AppMemoryUsageLimitChanged 與 AppMemoryUsageIncreased 的時候需要做記憶體的減少,避免 App 在 Background 被强迫 suspended 或 terminated。 釋放記憶體的方式參考<Background media playback sample>有:

  • 清除或設定在 Page 中 大型資料結構 為 null  
  • 取消在 Page 中注冊的所有事件,但要確保 Page_Load 的時候要能在注冊回來
  • 呼叫 GC.Collect 在設定大型資料結構 為 null 之後
  • 設定 Window.Current = null ,可觸發裏面所有 Frames 與 擁有的 Pages 進行 Unloaded 事件,等到所有參考都被移除後在呼叫 GC

介紹了運作的架構跟記憶體處理問題後,接下來説明重點的 MediaPlayer。

MediaPlayer API 同時支援 foreground  (有 UI) 與 background apps (沒有 UI),MediaElement 可用的關鍵特性都有支援。

使用時要先記得 new MediaPlayer() 。 MediaPlayer 提供預設的 SystemMediaTransportControls,如果需要其他的 API 可注冊處理 MediaPlayer.CommandManager 裏面的事件。

另外,要記得設定 SystemMediaTrannsportControls 的幾個重要屬性,這樣 App 被最小化的時候才會保持背景播放:

  1. IsEnabled = true
  2. IsPlayEnabled = true
  3. IsPauseEnabled = true
  4. 注冊處理 ButtonPressed 的事件

MediaPlayer 具有 MediaPlayerSurface 可用來渲染 video 内容到任何 Windows.UI.Composition 上(framework-less apps)。代表 XAML elements 都可以支援。 介紹幾個重點元素:

Type Name Description
Methods AddAudioEffect  Adds an audio effect to the playback stream of the MediaPlayer.
  AddVideoEffect Applies a video effect to media playback.
Properties AudioBalance Read/write. Gets or sets a ratio of volume across stereo speakers.
  BreakManager Read-only. Gets the MediaBreakManager associated with the MediaPlayer, which provides information about and control over media breaks for the player.
  CommandManager  Read-only. Gets the MediaPlaybackCommandManager associated with the MediaPlayer, which specifies the behavior of and receives events from the System Media Transport Controls. 處理 SystemMediaTransportControls 的事件改由 CommandManager 負責處理。例如: PlayReceived, PauseReceived, PreviousReceived... 等。 不過依舊可以使用 SystemMediaTransportControls 的 ButtonPressed 事件處理相關按鈕事件。
  PlaybackSession Read-only. Gets the MediaPlaybackSession associated with the MediaPlayer, which provides information about the state of the current playback session and provides events for responding to changes in playback session state. 新架構把目前播放狀態與相關事件都改到這裏,改用 playback session 當作一個單位回應狀態的改變,例如: buffering 的狀態,PlaybackRateChanged, SeekCompleted ... 等。
  SystemMediaTransportControls  Read-only. Gets an instance of the SystemMediaTransportControls class to enable user control of playback of the MediaPlayer and to allow the app to show information about the currently playing content in the system UI.
  TimelineController Read/write. Gets or sets the MediaTimelineController associated with the MediaPlayer. 新的元件,負責提供 MediaPlayer 目前的播放進度與狀態,方便整合到自行開發的 transport control。也支援同步管理多個 media player。解決過去要在畫面呈現播放進度條時需要利用 DispatchTimer 來做的困擾。
  TimelineControllerPositionOffset  Read/write. Gets or sets the offset applied to the position of the MediaTimelineController associated with the MediaPlayer. 代表目前播放進度。

新的機制讓控制 MediaPlayer 有更多機會,另外,如果原本是使用 BackgroundMediaPlayer.Current 的程式會遇到很多要求更換使用 PlaybackSession 與 SystemMediaTransportControls 相關事件/屬性的衝突要調整。 另外,介紹幾個其他重要元件:

提供方法控制目前播放項目與控制是否要 looping 或是 shuffling。因此,可在裏面加入多個 MediaPlaybackItem。

如果需要每首歌曲播放的時候做到無縫串接 (gapless playback),可以直接藉由它,系統會利用 MediaPlaybackItem 中 MP3 或 AAC 的 metadata 去決定如何實現無縫播放。

如果沒有提供 metadata 的話,系統會自動判別。目前不支援:PCM, FLAC, ALAC。

Type Name Description
Events CurrentItemChanged  Occurs when the currently playing MediaPlaybackItem changes.
  ItemFailed Occurs when an error is encountered with a MediaPlaybackItem in the playback list.
  ItemOpened Occurs when a MediaPlaybackItem in the playback list is successfully opened.
Methods SetShuffledItems Sets the list of MediaPlaybackItem objects that will be played in shuffle mode, in the order in which they will be played. 需要搭配 ShuffleEnabled 有設定為 true 才可以使用。
Properties MaxPrefetchTime Read/write. Gets or sets the maximum time before a MediaPlaybackItem in the list is expected to play that the media content associated with the item is retrieved. 
 
AutoRepeatEnabled  
Read/write. Gets or sets a value indicating whether the playback list will loop when the end of the list is reached.
  ShuffledItems Read-only. Gets a read-only list of of MediaPlaybackItem objects that will be played in shuffle mode, in the order in which they will be played. 它與 Items 屬性不同,它來自 SetShuffledItems() 給予的集合。
  StartingItem Read/write. Gets or sets the MediaPlaybackItem that will be played first.

代表可以被播放的項目 (audio tack 或 video track),媒體内容與 metadata 來自 MediaSource 所建立。可以直接指定給 MediaPlayer 或 MediaElement 播放,或是加入 MediaPlaybackList。

Type Name Description
Events AudioTracksChanged  Occurs when the list of audio tracks in the MediaSource associated with the MediaPlaybackItem changes.
  TimedMetadataTracksChanged  Occurs when the list of timed metadata tracks in the MediaSource associated with the MediaPlaybackItem changes.
  VideoTracksChanged Occurs when the list of video tracks in the MediaSource associated with the MediaPlaybackItem changes.
Methods ApplyDisplayProperties Updates the display properties for the MediaPlaybackItem.
  GetDisplayProperties Gets the display properties for a MediaPlaybackItem.
Properties BreakSchedule Read-only. Gets the MediaBreakSchedule defining the schedule of media breaks for the MediaPlaybackItem.
  AudioTracks Read-only. Gets a read-only list of audio tracks in the MediaSource associated with the MediaPlaybackItem.
  Source Read-only. Gets the MediaSource object associated with the MediaPlaybackItem.
  TimedMetadataTracks Read-only. Gets a read-only list of timed metadata tracks in the MediaSource associated with the MediaPlaybackItem.
  VideoTracks Read-only. Gets a read-only list of video tracks in the MediaSource associated with the MediaPlaybackItem.

代表多媒體來源 (media source),提供方法去參考來自不同的來源,并公開用於解析底層的 metadata 與 format。

Name Name Description
Methods CreateFromAdaptiveMediaSource  Creates an instance of MediaSource from the provided AdaptiveMediaSource.
  CreateFromUri Creates an instance of MediaSource from the provided Uri.
  CreateFromStream Creates an instance of MediaSource from the provided IRandomAccessStream.
  CreateFromMseStreamSource Creates an instance of MediaSource from the provided MseStreamSource.
  CreateFromMediaStreamSource Creates an instance of MediaSource from the provided MediaStreamSource.
  CreateFromMediaBinder Creates an instance of MediaSource from the provided MediaBinder.

然而,接著説明怎麽開發支援背景播放的音樂程式。

1. 在 Package.appmanifest 宣支援背景播放:


 <Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" 
         xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3" 
         IgnorableNamespaces="uap mp uap3">
  <Capabilities>
    <uap3:Capability Name="backgroundMediaPlayback" />
  </Capabilities>
 </Package>

 

2. 注冊處理 CommandMananger 中 SystemMediaTransportControls 的 ButtonPressed 事件與設定 IsEnabled 為 true:


public PlayerService()
{
    Player = new MediaPlayer();
    // 注冊處理 CommandManager 的 事件
    Player.CommandManager.IsEnabled = true;
    Player.CommandManager.PauseReceived += CommandManager_PauseReceived;
    Player.CommandManager.PlayReceived += CommandManager_PlayReceived;
    Player.CommandManager.NextReceived += CommandManager_NextReceived;
    Player.CommandManager.PreviousReceived += CommandManager_PreviousReceived;

    // 注冊處理目前播放器的狀態
    Player.PlaybackSession.PlaybackStateChanged += PlaybackSession_PlaybackStateChanged;
}

private void PlaybackSession_PlaybackStateChanged(MediaPlaybackSession sender, object args)
{
    // 通知 UI 畫面或是相關因爲播放狀態改變要處理的事情
    CurrentState = sender.PlaybackState;
    StateChanged?.Invoke(this, CurrentState);
}

private void CommandManager_PauseReceived(MediaPlaybackCommandManager sender, MediaPlaybackCommandManagerPauseReceivedEventArgs args)
{
    Pause();
}

private void CommandManager_PlayReceived(MediaPlaybackCommandManager sender, MediaPlaybackCommandManagerPlayReceivedEventArgs args)
{
    Play();
}

private void CommandManager_PreviousReceived(MediaPlaybackCommandManager sender, MediaPlaybackCommandManagerPreviousReceivedEventArgs args)
{
    Previous();
}

private void CommandManager_NextReceived(MediaPlaybackCommandManager sender, MediaPlaybackCommandManagerNextReceivedEventArgs args)
{
    Next();
}

 

3. 利用 MediaPlaybackList 加入多首歌曲,進行播放:


public MediaPlaybackList MediaPlaybackList { get; private set; }

private void BuildMediaPlaybackList()
{
    for (int i = 1; i < 5; i++)
    {
        string file = $"ms-appx:///Assets/mp3/0{i}.mp3";
        // 利用 MediaSource 取得播放來源
        MediaSource source = MediaSource.CreateFromUri(new Uri(file, UriKind.RelativeOrAbsolute));
        MediaPlaybackItem item = new MediaPlaybackItem(source);
        // 更新播放項目的相關描述
        MediaItemDisplayProperties displayProperty = item.GetDisplayProperties();
        displayProperty.Type = MediaPlaybackType.Music;
        displayProperty.MusicProperties.Title = $"0{i}.mp3";
        displayProperty.MusicProperties.AlbumArtist = "JJ";
        displayProperty.Thumbnail = RandomAccessStreamReference.CreateFromUri(new Uri($"ms-appx:///Assets/mp3/0{i}.jpg", UriKind.RelativeOrAbsolute));
        item.ApplyDisplayProperties(displayProperty);

        PlaybackList.Add(new MediaPlaybackItemDataWrapper(item));
        // 加入 MediaPlaybackList.Item (循序播放)
        MediaPlaybackList.Items.Add(item);
    }

    // 最後提供 MediaPlaybackList 給 MediaPlayer.Source 作爲播放内容
}

每一首歌曲要利用 MediaSource 將要播放的 URI 變成 MediaSource,從 MediaSource.GetDisplayProperties 得到之後要更新 SystemMediaTransportControls 的内容。

3-1. 處理播放上下一首歌曲,直接使用 MediaPlaybackList 的 MovePrevious() 與 MoveNext():


internal void Previous()
{
    var playbackList = Player.Source as MediaPlaybackList;

    if (playbackList == null)
    {
	return;
    }

    playbackList.MovePrevious();
}

internal void Next()
{
    var playbackList = Player.Source as MediaPlaybackList;

    if (playbackList == null)
    {
	return;
    }

    playbackList.MoveNext();
}

 

4. 注冊處理 App 的 EnterBackground() 與 LeaveBackground():


private void App_LeavingBackground(object sender, LeavingBackgroundEventArgs e)
{
    isInBackgroundMode = false;

    // 回到 foreground 前要記得恢復畫面
    if (Window.Current.Content == null)
    {
        CreateRootFrame(ApplicationExecutionState.Running, string.Empty);
    }
}

private void CreateRootFrame(ApplicationExecutionState previousExecutionState, string arguments)
{
    Frame rootFrame = Window.Current.Content as Frame;

    // Do not repeat app initialization when the Window already has content,
    // just ensure that the window is active
    if (rootFrame == null)
    {
        System.Diagnostics.Debug.WriteLine("CreateFrame: Initializing root frame ...");

        // Create a Frame to act as the navigation context and navigate to the first page
        rootFrame = new Frame();

        // Set the default language
        rootFrame.Language = Windows.Globalization.ApplicationLanguages.Languages[0];

        rootFrame.NavigationFailed += OnNavigationFailed;

        if (previousExecutionState == ApplicationExecutionState.Terminated)
        {
            //TODO: Load state from previously suspended application
        }

        // Place the frame in the current Window
        Window.Current.Content = rootFrame;
    }

    if (rootFrame.Content == null)
    {
        // When the navigation stack isn't restored navigate to the first page,
        // configuring the new page by passing required information as a navigation
        // parameter
        rootFrame.Navigate(typeof(MainPage), arguments);
    }

    // Ensure the current window is active
    Window.Current.Activate();
}

private void App_EnteredBackground(object sender, EnteredBackgroundEventArgs e)
{
    // 不要立即執行畫面的清除因爲有可能用戶會馬上回來
    isInBackgroundMode = true;
}

 

5. 注冊處理 MemoryManager 的相關事件:


/// <summary>
/// Called from App.xaml.cs when the application is constructed.
/// </summary>
partial void Construct()
{
    // During the transition from foreground to background the
    // memory limit allowed for the application changes. The application
    // has a short time to respond by bringing its memory usage
    // under the new limit.
    MemoryManager.AppMemoryUsageLimitChanging += MemoryManager_AppMemoryUsageLimitChanging;

    // After an application is backgrounded it is expected to stay
    // under a memory target to maintain priority to keep running.
    // Subscribe to the event that informs the app of this change.
    MemoryManager.AppMemoryUsageIncreased += MemoryManager_AppMemoryUsageIncreased;

    // Subscribe to key lifecyle events to know when the app
    // transitions to and from foreground and background.
    // Leaving the background is an important transition
    // because the app may need to restore UI.
    EnteredBackground += App_EnteredBackground;
    LeavingBackground += App_LeavingBackground;

    // Subscribe to regular lifecycle events to display a toast notification
    Suspending += App_Suspending;
    Resuming += App_Resuming;
}

5-1. 處理 降低記憶體 的方法:


public void ReduceMemoryUsage(ulong limit)
{
    // If the app has caches or other memory it can free, now is the time.
    // << App can release memory here >>
    // Additionally, if the application is currently
    // in background mode and still has a view with content
    // then the view can be released to save memory and 
    // can be recreated again later when leaving the background.
    if (isInBackgroundMode && Window.Current.Content != null)
    {
       Debug.WriteLine("Unloading view");

       // Clear the view content. Note that views should rely on
       // events like Page.Unloaded to further release resources. Be careful
       // to also release event handlers in views since references can
       // prevent objects from being collected. C++ developers should take
       // special care to use weak references for event handlers where appropriate.
       Window.Current.Content = null;

       // Finally, clearing the content above and calling GC.Collect() below 
       // is what will trigger each Page.Unloaded handler to be called.
       // In order for the resources each page has allocated to be released,
       // it is necessary that each Page also call GC.Collect() from its
       // Page.Unloaded handler.
    }

    // Run the GC to collect released resources, including triggering
    // each Page.Unloaded handler to run.
    GC.Collect();

    Debug.WriteLine("Finished reducing memory usage");
}

/// <summary>
/// Gets a string describing current memory usage
/// </summary>
/// <returns>String describing current memory usage</returns>
private string GetMemoryUsageText()
{
    return string.Format("[Memory: Level={0}, Usage={1}K, Target={2}K]",
            MemoryManager.AppMemoryUsageLevel, MemoryManager.AppMemoryUsage / 1024, 
            MemoryManager.AppMemoryUsageLimit / 1024);
}

5-2. 處理 AppMemoryUsageIncreased 事件,如果用量等級是 High 或 OverLimit 就要降低記憶體


private void MemoryManager_AppMemoryUsageIncreased(object sender, object e)
{
    Debug.WriteLine("Memory usage increased");

    // Obtain the current usage level
    var level = MemoryManager.AppMemoryUsageLevel;

    // Check the usage level to determine whether reducing memory is necessary.
    // Memory usage may have been fine when initially entering the background but
    // a short time later the app might be using more memory and need to trim back.
    if (level == AppMemoryUsageLevel.OverLimit || level == AppMemoryUsageLevel.High)
    {
        ReduceMemoryUsage(MemoryManager.AppMemoryUsageLimit);
    }
}

5-3. 處理 AppMemoryUsageIncreased 事件,如果用量等級是 High 或 OverLimit 就要降低記憶體


private void MemoryManager_AppMemoryUsageLimitChanging(object sender, AppMemoryUsageLimitChangingEventArgs e)
{
    Debug.WriteLine("Memory usage limit changing from "
                     + (e.OldLimit / 1024) + "K to "
                     + (e.NewLimit / 1024) + "K");

    // If app memory usage is over the limit about to be enforced,
    // then reduce usage within 2 seconds to avoid suspending.
    if (MemoryManager.AppMemoryUsage >= e.NewLimit)
    {
        ReduceMemoryUsage(e.NewLimit);
   }
}

記憶體釋放的方式很多種,上面的範例是參考<BackgroundActivation sample> 做的調整。

我測試下來覺得最有用的就是放掉衹有在 UI 中使用到的 Instance, static 的物件或是屬性,然後要清楚乾净事件注冊,最後一定要叫 GC,不過不是什麽狀況都適合,

因爲有遇到用了 GC, 但是突然回到 Foreground 原本的東西反而被回收了。

 

[範例程式] DotblogsSampleCode/BackgroundMediaPlayerSample/SingleBackgroundMediaPlayer/

[補充]

======

以上介紹了怎麽開發新的 Background Media Player,之前開發支援 Mobile/Desktop 要相容必須選擇 BackgroundMediaPlayer 做 dual-process 架構來支援,現在是不是覺得比以前簡單很多。

如果您的 App 用戶已經都在 14393 之後的 OS 就可以調整支援新的架構。

希望對大家有幫助,謝謝。

 

References