老司机学新平台 - Xamarin开发之我的第一个MvvmCross跨平台插件:SimpleAudioPlayer

大家好,老司机学Xamarin系列又来啦!上一篇MvvmCross插件精选文末提到,Xamarin平台下,一直没找到一个可用的跨平台AudioPlayer插件。那就自力更生,让我们就自己来写一个吧!

源码和Nuget包

源码:https://github.com/teddymacn/Teddy-MvvmCross-Plugins

Nuget包:https://www.nuget.org/packages/Teddy.MvvmCross.Plugin.SimpleAudioPlayer/

MvvmCross的PCL+Native插件架构简介

在开始写一个MvvmCross插件之前,先简单介绍一下MvvmCross的插件架构。MvvmCross的插件,一般有三种类型:纯PCL,PCL+Native和Configurable插件。本文介绍的是,最典型最常用的一种插件类型,即PCL+Native,简单的说,就是一个PCL的Portable项目包含服务的接口,各个Platform特定的Xamarin Native项目包含不同平台的接口实现。

PCL项目除了需要包含一个服务接口外,还会包含一个PluginLoader类,这个类有一个标准实现,和我们要实现的自定义功能没关系,只是调用的MvvmCross框架的相关类,它的代码一般固定是这样的:

public class PluginLoader
    : IMvxPluginLoader

{
    public static readonly PluginLoader Instance = new PluginLoader();

    public void EnsureLoaded()
    {
        var manager = Mvx.Resolve<IMvxPluginManager>();
        manager.EnsurePlatformAdaptionLoaded<PluginLoader>();
    }
}

在一个MvvmCross项目启动时,PluginLoader.Instance.EnsureLoaded()会被自动调用,通过反射装载项目中定义的真正的插件。

在每个平台特定的Xamarin项目中,则通常要包含一个Plugin类,Plugin类只有一个Load()方法需要实现,用来在项目启动时,自动向MvvmCross的IoC容器中注册插件的接口实现。比如,本文要实现的SimpleAudioPlayer插件,它的Plugin类,它的Droid版本是这样的:

namespace Teddy.MvvmCross.Plugins.SimpleAudioPlayer.Droid
{
    public class Plugin
        : IMvxPlugin
    {
        public void Load()
        {
            Mvx.RegisterType<IMvxSimpleAudioPlayer, MvxSimpleAudioPlayer>();
        }
    }
}

在使用这个插件的具体的Xamarin App的Bootstrop目录中,一般当我们添加一个MvvmCross插件的nuget package时,package会自动为每个插件创建一各PluginBootstrap类,只有App包含了PluginBootstrap类,对应的插件才会被MvvmCross框架自动装载。比如,我们的SimpleAudioPlayer插件的package,如果在一个Droid App里面被引用,它会向Bootstrap目录里自动添加一个SimpleAudioPlayerPluginBootstrap类如下:

public class SimpleAudioPlayerPluginBootstrap
    : MvxPluginBootstrapAction<Teddy.MvvmCross.Plugins.SimpleAudioPlayer.PluginLoader>
{ }

上面就是一个PCL+Native插件包含的所有元素。一旦根据这些命名规范,装载了一个插件,我们就可以在ViewModel里面,通过构造函数注入,或者通过调用Mvx.Resolve()获取我们的接口的实例了。比如,在我们的Demo项目中,通过构造函数注入,得到了插件接口的实例:

public class MainViewModel : BaseViewModel
{
    private readonly IMvxSimpleAudioPlayer _player;
    private readonly IMvxFileStore _fileStore;

    public MainViewModel(IMvxSimpleAudioPlayer player
        , IMvxFileStore fileStore
        )
    {
        _player = player;
        _fileStore = fileStore;
    }
    
    ...

关于其他类型的MvvmCross插件的介绍,请参见官方文档

需求定义

我们来列一下我们要实现的插件的需求:

  • 实现一个跨平台(Droid,iOS,UWP)支持在线(by URL)和本地(打包到App)文件的常见audio文件(至少支持mp3)播放;
  • 支持MvvmCross的插件架构

项目结构

定义Portable接口

首先,我们需要新建一个跨平台的Portable项目Teddy.MvvmCross.Plugins.SimpleAudioPlayer,包含这个播放器的基本接口:

public interface IMvxSimpleAudioPlayer : IDisposable
{
    /// <summary>
    /// Gets the current audio path.
    /// </summary>
    string Path { get;}

    /// <summary>
    /// Gets the duration of the audio in milliseconds.
    /// </summary>
    double Duration { get; }

    /// <summary>
    /// Gets the current position in milliseconds.
    /// </summary>
    double Position { get; }

    /// <summary>
    /// Whether or not it is playing.
    /// </summary>
    bool IsPlaying { get; }

    /// <summary>
    /// Gets or sets the current volume.
    /// </summary>
    double Volume { get; set; }

    /// <summary>
    /// Opens a specified audio path.
    /// 
    /// The following formats of path are supported:
    ///     - Absolute URL, 
    ///       e.g. http://abc.com/test.mp3
    ///       
    ///     - Assets Deployed with App, relative path assumed to be in the device specific assets folder
    ///       Android and UWP relative to the Assets folder while iOS relative to the App root folder
    ///       e.g. test.mp3
    ///       
    ///     - Local File System, arbitry local absolute file path the app has access
    ///       e.g. /sdcard/test.mp3
    /// </summary>
    /// <param name="path">
    ///     The audio path.
    /// </param>
    bool Open(string path);

    /// <summary>
    /// Plays the opened audio.
    /// </summary>
    void Play();

    /// <summary>
    /// Stops playing.
    /// </summary>
    void Stop();

    /// <summary>
    /// Pauses the playing.
    /// </summary>
    void Pause();

    /// <summary>
    /// Seeks to specified position in milliseconds.
    /// </summary>
    /// <param name="pos">The position to seek to.</param>
    void Seek(double pos);

    /// <summary>
    /// Callback at the end of playing.
    /// </summary>
    event EventHandler Completion;
}

注释已经自描述了,就不多解释了。简单的说,我们的播放器支持Open一个audio文件,然后可以Play,Stop,Pause等等。离全功能的音乐播放器还差得远,不过,用来实现app中各种简单的在线和本地mp3播放控制应该足够了。

Droid实现

Droid的实现是Teddy.MvvmCross.Plugins.SimpleAudioPlayer.Droid项目中的MvxSimpleAudioPlayer类。安卓的媒体播放一般都基于安卓SDK的MediaPlayer类,代码并不复杂,但是,有一些坑。

坑一:

首先是播放不同来源(URL,本地或Assets中的)的文件,Load文件的方式有差异:

_player = new MediaPlayer();

if (Path.StartsWith(Root) || Uri.IsWellFormedUriString(Path, UriKind.Absolute))
{
    // for URL or local file path, simply set data source
    _player.SetDataSource(Path);
}
else
{
    // search for files with relative path in Assets folder
    // files in the Assets folder requires to be opened with a FileDescriptor
    var descriptor = Application.Context.Assets.OpenFd(Path);
    long start = descriptor.StartOffset;
    long end = descriptor.Length;
    _player.SetDataSource(descriptor.FileDescriptor, start, end);
}

对于在线的URL和绝对路径的本地文件,只需要设置MediPlayer的SetDataSource()就可以了;但是对于Assets目录中,和App一起打包发布的资源,必须通过Assets.OpenFd()打开,才能设置SetDataSource()。

坑二:

MediaPlayer调用Stop()之后,重新播放之前必须重新Prepare(),否则会报错:

public void Stop()
{
    if (_player == null) return;

    if (_player.IsPlaying)
    {
        _player.Stop();

        // after _player.Stop(), re-prepare the audio, otherwise, re-play will fail
        _player.Prepare();

        _player.SeekTo(0);
    }
}

坑三:

销毁一个MediaPlayer的实例之前,必须先调用Reset()方法,否则,Xamarin主程序不会报错,但是,Debug日志会显示内部有exception,可能会导致内存泄漏:

private void ReleasePlayer()
{
    // stop
    if (_player.IsPlaying) _player.Stop();

    // for android, thr call to Reset() is required before calling Release()
    // otherwise, an exception will be thrown when Release() is called
    _player.Reset();

    // release the player, after what the player could not be reused anymore
    _player.Release();
}

完整的源代码可以看这里:MvxSimpleAudioPlayer.cs

iOS实现

iOS实现在Teddy.MvvmCross.Plugins.SimpleAudioPlayer.iOS项目的MvxSimpleAudioPlayer类。iOS下的音频播放一般通过SDK的AVPlayer或者AVAudioPlayer类,我也不是iOS的专家,不太清楚两个有啥渊源,最开始尝试使用AVAudioPlayer,但是,播放本地文件没问题,播放URL遇到了各种问题,最后也没有解决。换成使用AVPlayer以后,顺畅了很多。如果有知道什么时候应该使用AVAudioPlayer而不是AVPlayer的,望不吝告知。

使用AVPlayer播放mp3的整个过程,要比安卓下的MediaPlayer顺畅很多。有两点需要注意的:

注意一:

Load不同来源的文件,注意使用不同的格式的URL:

AVAsset audioAsset;
if (Uri.IsWellFormedUriString(Path, UriKind.Absolute))
    audioAsset = AVAsset.FromUrl(NSUrl.FromString(Path));
else if (Path.StartsWith(Root))
    audioAsset = AVAsset.FromUrl(NSUrl.FromString("file://" + Path));
else
    audioAsset = AVAsset.FromUrl(NSUrl.FromFilename(Path));

_timeScale = audioAsset.Duration.TimeScale;
var audioItem = AVPlayerItem.FromAsset(audioAsset);
_player = AVPlayer.FromPlayerItem(audioItem);

上面的代码组要注意的是,当Path是相对路径时,NSUrl.FromFilename(Path)生成的绝对路径是相对于App主程序目录的。

注意二:

和Droid下MediaPlayer直接包含Completion事件回掉,能够知道一次播放已经完成不同,AVPlayer上面没有这类通知包装成.NET事件,而且也没有专门的Play Completion这样的事件,不过,AVPlayer包含一个AddBoundaryTimeObserver()方法,可以在音频播放到指定的进度时,回调指定的方法,所以,也可以实现类似Completion事件的通知:

_player.AddBoundaryTimeObserver(
    times: new[] { NSValue.FromCMTime(audioAsset.Duration) },  // callback when reach end of duration
    queue: null,
    handler: () => Seek(0));

完整的源代码可以看这里:MvxSimpleAudioPlayer.cs

UWP实现

这里的UWP实现,目前只支持uap10.0这个target。编译的程序在Win10上运行是没问题的,别的UWP支持的环境没测过,对WinPhone也不是很了解,如果对这方面有需要的朋友,自己做一下扩展吧。

UWP的实现在是Teddy.MvvmCross.Plugins.SimpleAudioPlayer.UWP项目的MvxSimpleAudioPlayer类。这里并没有像Droid和iOS那样每次实例化一个内部的player实例,而是调用了BackgroundMediaPlayer.Current这个默认MediaPlayer实例。

微软自己的Player还是封装的非常好的,使用非常简单,唯一值得一提的是,Load Assets目录中的文件时,需要指定一个特别的protocol:

if (Uri.IsWellFormedUriString(Path, UriKind.Absolute) || Path.Contains(Drive))
    _player.Source = MediaSource.CreateFromUri(new Uri(path, UriKind.Absolute));
else
    _player.Source = MediaSource.CreateFromUri(new Uri(string.Format("ms-appx:///Assets/" + path, UriKind.Absolute)));

完整的源代码可以看这里:MvxSimpleAudioPlayer.cs

好了,不同平台的实现就介绍到这里。下面来看看示例程序。

示例程序

本项目的源码同时包含了Droid,iOS和UWP各平台的Demo程序,可以直接运行体验。示例程序包含了一个简单的UI,演示了播放Assets里的mp3文件,mp3 URL和从远程URL下载到本地的mp3。

调用IMvxSimpleAudioPlayer接口播放的代码,主要在MainViewModel中,播放不同来源文件的示例在OpenAudio()方法中:

private void OpenAudio()
{
    // for testing with remote audio, you need to setup a web server to serve the test.mp3 file
    // and please change the server address below
    // according to your local machine, device or emulator's network settings

    string server = (Device.OS == TargetPlatform.Android) ?
        "http://169.254.80.80" // default host address for Android emulator
        :
        "http://192.168.2.104"; // my local machine's intranet ip, change to your server's instead

    // by default, testing playing audio from Assets
    _player.Open("test.mp3");
        _player.Volume = 1;
    _player.Play();

    // comment the code above and uncomment the code below
    // if you want to test playing a remote audio by URL
    //_player.Open(server + "/test.mp3");
    //_player.Play();

    // comment the code above and uncomment the code below
    // if you want to test playing a downloaded audio
    //var request = new MvxFileDownloadRequest(server + "/test.mp3", "test.mp3");
    //request.DownloadComplete += (sender, e) =>
    //{
    //    _player.Open(_fileStore.NativePath("test.mp3"));
    //    _player.Play();
    //};
    //request.Start();
}

上面的OpenAudio()方法中,默认播放的是,打包到App的Assets里的mp3文件,两外两个被注释掉的版本,则分别是播放URL,和下载URL到本地mp3再播放。下载文件的部分,使用了MvvmCross官方的DownloadCache插件和File插件。

URL地址可能需要根据你的本地情况自己设置了,可以将Droid Demo的Assets目录里的test.mp3放到比本机的某个web server下面。注意,安卓模拟器访问的ip只能是对应安卓模拟器的虚拟网卡的ip。在我本机上安卓SDK模拟器的虚拟网卡ip是169.254.80.80,Android Emulator for Visual Studio的虚拟网卡ip是192.168.17.1。这个不确定每个机器上是不是一样,具体的可以在cmd里面执行ipconfig /all看到,你也可以先在模拟器里的browser里面访问试试。

安卓的运行效果如下:

iOS运行效果如下:

UWP在Win10下运行如下:

其他注意事项:

在Droid下,从URL播放音频需要设置INTERNET权限:

在iOS下,从非https的URL播放音频需要在项目根目录的info.plist文件中配置NSAppTransportSecurity参数,否则无法播放:

        ...
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSAllowsArbitraryLoads</key>
        <true/>
    </dict>
</dict>
</plist>

在UWP下,可能因为UWP App的项目是.Net Core格式的项目类型,nuget package的自动往Bootstrap目录自动添加PluginBoorstrap类的功能,貌似不work,这个感觉算是VS 2015的Package Manager的bug。anyway,如果它没有自动添加,用户可以参考UWP的Demo手动添加。

就是这么多了,enjoy!

PS:虽然是‘老’司机,不过对Xamarin和安卓、iOS和UWP开发都是刚接触不久,如有任何疏漏或者错误,请不吝指正,共同学习,谢谢!

2016-10-23 Update:

  • 将SimpleAudioPlayer升级到了1.0.5,新增了Position,IsPlaying和Volume属性。
  • 另外,在Xamarin-Forms-Labs这个开源项目里,终于发现一个ISoundService,同样实现了Xamarin下的Droid,iOS和UWP下的mp3播放,不过它只支持本地Assets中的文件播放,并不支持本地绝对路径和在线URL的播放。功能上被SimpleAudioPlayer完全压倒!不过,咱的新版本新增了Position,IsPlaying和Volume属性是受它启发,这几个确实是必须的属性参数,所以,还是要感谢人家的!
  • 22:30, 再次将SimpleAudioPlayer升级到了1.0.6,新增了Completion事件,代表一次播放结束。
posted @ 2016-10-22 17:33 Teddy's Knowledge Base Views(...) Comments(...) Edit 收藏