Xamarin.Forms学习之路——Media实战(多种库的用法以及原生写法)、.Android和.Forms的通信

项目效果

banner展示

在这里插入图片描述

音乐播放器以及文件选择

音乐播放器

在这里插入图片描述

文件选择

在这里插入图片描述

相册展览

瀑布流展示

在这里插入图片描述

拍照

在这里插入图片描述
在这里插入图片描述

相册

在这里插入图片描述
在这里插入图片描述

录音功能

在这里插入图片描述

学习目标

  1. 瀑布流图片布局;
  2. FFimageLoading的用法;
  3. 基于CrossMedia的照相机获取图片及本地图片的获取;
  4. 基于MediaMannager的播放器功能;
  5. 基于安卓系统的文件选择功能;
  6. 基于Android原生和AudioRecorder的录音功能;
  7. 展示RefreshView的用法;
  8. 基于CarouselView的Banner制作方法;
  9. 沉浸式布局的方法;

项目结构(iOS不做展示)

在这里插入图片描述
在这里插入图片描述

项目准备

  1. Xamarin.Forms
    在这里插入图片描述
  2. Xamarin.Android 添加CurrentActivity包就行
    在这里插入图片描述
  3. Android Properties设置
    在这里插入图片描述

步骤

全局构建

根据VS2019自动生成Shell页,可以从中知道一些MVVM编码范式,从而可以构建我们的整个项目

在Services文件夹里新建IDataStore接口

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;

namespace Conclusion.Services
{
    public interface IDataStore<T>
    {
        Task<bool> AddObjAsync(T _object);
        Task<bool> UpdateObjAsync(T _object);
        Task<bool> DeleteObjAsync(string id);
        Task<T> GetObjAsync(string id);
        Task<IEnumerable<T>> GetObjsAsync(bool foreceRefreshing = false);
    }
}

建立IDataStore接口的目的是为了方便以后的网络版本。现在我只是用的MockDataStore作为项目数据。

建立MockDataStore.cs类,拓展IDataStore接口

using Conclusion.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xamarin.Forms;
namespace Conclusion.Services
{
    public class MockDataStore : IDataStore<Item>
    {
        public readonly List<Item> items;
        
        public MockDataStore(Type _type)
        {
            if(_type == typeof(Player))
            {
                items = new List<Item>
                {

                };
             
            }
            else if(_type == typeof(Photo))
            {
                var imageUrls = new List<Photo>();
                for(int i = 1; i <= 17; i++)
                {
                    imageUrls.Add(new Photo { 
                        PhotoUrl = "img" + i + ".jpg"
                    });
                }
                items = new List<Item>(imageUrls);
            }
            else if(_type == typeof(Refresh))
            {
                items = new List<Item>
                {
                    new Refresh
                    {
                        BannerUrl="banner1.jpg",
                        BannerColor=Color.AliceBlue
                    },
                    new Refresh
                    {
                        BannerUrl="banner2.jpg",
                        BannerColor=Color.PaleVioletRed
                    },
                    new Refresh
                    {
                        BannerUrl="banner3.jpg",
                        BannerColor=Color.DarkKhaki
                    }
                };
                System.Diagnostics.Debug.WriteLine("count:"+items.Count);
            }
     
        }
        #region 对数据的一系列操作
        public async Task<bool> AddObjAsync(Item _item)
        {
            items.Add(_item);
            return await Task.FromResult(true);
        }

        public async Task<bool> DeleteObjAsync(string id)
        {
            Item oldItem = items.Where((Item arg) => arg.Id == id).FirstOrDefault();
            items.Remove(oldItem);
            return await Task.FromResult(true);
        }

        public async Task<Item> GetObjAsync(string id)
        {
            return await Task.FromResult(items.FirstOrDefault((Item arg) => arg.Id == id));
        }

        public async Task<IEnumerable<Item>> GetObjsAsync(bool foreceRefreshing = false)
        {
            return await Task.FromResult(items);
        }

        public async Task<bool> UpdateObjAsync(Item _item)
        {
            var oldItem = items.Where((Item arg) => arg.Id == _item.Id).FirstOrDefault();
            items.Remove(oldItem);
            items.Add(_item);

            return await Task.FromResult(true);
        }
        #endregion
    }
}

Store的全局初始化

下面是介绍,大佬可直接跳过


这里我用到了比较骚的操作,可以看到,我的Model中有4个类,其中Item类是一个基类
在这里插入图片描述
Item类的定义很简单:

using System;
using System.Collections.Generic;
using System.Text;

namespace Conclusion.Models
{
    public class Item
    {
        public string Id { get; set; }
    }
}

其他三个类均继承于Item类。
这么做的目的是为了增强代码的可重用性。大家可以再看一遍MockDataStore.cs代码:
在这里插入图片描述
这样一来,只需要对当前数据类型进行判断,从而抓取不同的数据,从而就不需要写三个DataStore了。


在App.xaml.cs 中实现Store的全局初始化

using System;
using Xamarin.Forms;
using Xamarin.Forms.Xaml;
using Conclusion.Views;
using Conclusion.Services;
using Conclusion.Models;

namespace Conclusion
{
    public partial class App : Application
    {
        public static MockDataStore PhotoStore{get;set;}
        public static MockDataStore PlayerStore{get;set;}
        public static MockDataStore RefreshStore{get;set;}
        public App()
        {
            InitializeComponent();
            
            PhotoStore = new MockDataStore(typeof(Photo));
            PlayerStore = new MockDataStore(typeof(Player));
            RefreshStore = new MockDataStore(typeof(Refresh));

            MainPage = new AppShell();
        }

        protected override void OnStart()
        {
        }

        protected override void OnSleep()
        {
        }

        protected override void OnResume()
        {
        }
    }
}

.Android.MainActivity.cs

  1. 重写Oncreate方法
protected override void OnCreate(Bundle savedInstanceState)
{
     

     TabLayoutResource = Resource.Layout.Tabbar;
     ToolbarResource = Resource.Layout.Toolbar;

     base.OnCreate(savedInstanceState);
     //包的初始化
     NugetInit(savedInstanceState);
     //布局的初始化
     LayOutInit();
     Xamarin.Essentials.Platform.Init(this, savedInstanceState);
     global::Xamarin.Forms.Forms.Init(this, savedInstanceState);
     LoadApplication(new App());

     //写一个函数来提供权限
     CheckPermisson();
     
 }
  1. LayOutInit()函数
private void LayOutInit()
{
    var newUiOptions = (int)SystemUiFlags.LayoutStable;
    newUiOptions |= (int)SystemUiFlags.LayoutFullscreen;
    //newUiOptions |= (int)SystemUiFlags.HideNavigation;
    Window.SetStatusBarColor(Android.Graphics.Color.Argb(0, 0, 0, 0));
    newUiOptions |= (int)SystemUiFlags.ImmersiveSticky;
    Window.DecorView.SystemUiVisibility = (StatusBarVisibility)newUiOptions;
}
  1. NuGetInit()函数
private void NugetInit(Bundle savedInstanceState)
{
	//Nuget包的初始化,这些在文档中可以参考借鉴,以后编码把要装的包的初始化写在这个函数中即可
    Forms.SetFlags("CollectionView_Experimental");
    CrossCurrentActivity.Current.Init(this, savedInstanceState);
    ButtonCircleRenderer.Init();
    CachedImageRenderer.Init(enableFastRenderer: true);
    CachedImageRenderer.InitImageViewHandler();
    
    CrossMediaManager.Current.Init(this);
}
  1. CheckPermisson()函数
private void CheckPermisson()
{
    //检查录音权限
    if (ContextCompat.CheckSelfPermission(this, Manifest.Permission.RecordAudio) != Permission.Granted)
    {
        ActivityCompat.RequestPermissions(this, new string[] { Manifest.Permission.RecordAudio }, 1);
    }
    //检查外部存储 读写权限
    if (ContextCompat.CheckSelfPermission(this, Manifest.Permission.ReadExternalStorage) != Permission.Granted ||
        ContextCompat.CheckSelfPermission(this, Manifest.Permission.WriteExternalStorage) != Permission.Granted)
    {
        ActivityCompat.RequestPermissions(this, new string[] {Manifest.Permission.ReadExternalStorage,
            Manifest.Permission.WriteExternalStorage}, 1);
    }

}

ViewModels.BaseViewModel.cs

using System;
using System.Collections.Generic;
using System.Text;
using Conclusion.Services;
using Xamarin.Forms;

namespace Conclusion.ViewModels
{
    class BaseViewModel:BindableObject
    {
        private bool _isRefresh;
        public bool IsRefresh
        {
            get { return _isRefresh; }
            set
            {
                if (_isRefresh == value)
                    return;
                _isRefresh = value;
                OnPropertyChanged();
            }
        }
    }
}

这里写BassViewModel类的目的主要是更好让“RefreshView”重用,其他ViewModel继承BaseViewModel就好了

Banner页

为什么先从Banner开始,因为他最简单

Models.Refresh.cs

using System;
using System.Collections.Generic;
using System.Text;
using Xamarin.Forms;

namespace Conclusion.Models
{
    class Refresh:Item
    {
        public string BannerUrl { get; set; }
        public Color BannerColor { get; set; }
    }
}

为什么取名叫Refresh呢?因为当时脑子抽了,大家大可以修改成Banner。

ViewModels.RefreshViewModel.cs

using System;
using System.Collections.Generic;
using System.Text;
using Conclusion.Services;
using Conclusion.Models;
using Xamarin.Forms;
using System.ComponentModel;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using System.Diagnostics;

namespace Conclusion.ViewModels
{
    class RefreshViewModel : BaseViewModel
    {
        private readonly MockDataStore refreshService;
        private ObservableCollection<Refresh> _banners;
        public Command RefreshViewCommand { get; set; }

        public ObservableCollection<Refresh> Banners
        {
            get { return _banners; }
            set
            {
                if (_banners == value)
                    return;
                _banners = value;
                OnPropertyChanged();
            }
        }


        public RefreshViewModel()
        {
        	//初始化refreshService
            refreshService = App.RefreshStore;
            //为Refresh绑定命令
            RefreshViewCommand = new Command(async () => await ExecuteRefreshViewCommand());
            
            //初始化Banners
            if(Banners == null)
            {
                Banners = new ObservableCollection<Refresh>();
                RefreshViewCommand.Execute(null);
            }
        }

        private async Task ExecuteRefreshViewCommand()
        {
            IsRefresh = true;
            try
            {
                Banners.Clear();
                var banners = await refreshService.GetObjsAsync(true);
                foreach(Refresh banner in banners)
                {
                    Banners.Add(banner);
                }
                Debug.WriteLine("banners:"+Banners.Count);
            }
            catch(Exception ex)
            {
                Debug.WriteLine(ex);
            }
            finally
            {
                IsRefresh = false;
            }
        }
    }
}

Views.RefreshView.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:d="http://xamarin.com/schemas/2014/forms/design"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             mc:Ignorable="d"
             x:Class="Conclusion.Views.RefreshView">
    <!--这一页展示RefreshView的用法,以及基于CarouselView的Banner制作方法-->

    <RefreshView
        IsRefreshing="{Binding IsRefresh}"
        Command="{Binding RefreshViewCommand}">
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="250"></RowDefinition>
                <RowDefinition ></RowDefinition>
            </Grid.RowDefinitions>
            <!--#region Banner区域-->
            <CarouselView
                Grid.Row="0"
                PeekAreaInsets="0"
                ItemsSource="{Binding Banners}"
                CurrentItemChangedCommand="{Binding CurrentItemChanged}"
                x:Name="myBanner"
                Margin="0,20,0,0">
                <CarouselView.ItemsLayout>
                    <LinearItemsLayout
                        SnapPointsType="Mandatory"
                        SnapPointsAlignment="Center"
                        Orientation="Horizontal"
                        ItemSpacing="20"></LinearItemsLayout>
                </CarouselView.ItemsLayout>
                <CarouselView.ItemTemplate>
                    <DataTemplate>
                        <Frame
                            VerticalOptions="Start"
                            HorizontalOptions="Start"
                            HeightRequest="200"
                            WidthRequest="300"
                            Padding="0" 
                            HasShadow="True"
                            IsClippedToBounds="true"
                            CornerRadius="10"
                            BackgroundColor="{Binding BannerColor}">

                            <Image Source="{Binding BannerUrl}"
                                   Aspect="AspectFill"></Image>

                        </Frame>
                    </DataTemplate>
                </CarouselView.ItemTemplate>
            </CarouselView>
            <!--#endregion-->
            <Label 
                   Grid.Row="1"
                   Text="这里是Banner展示"
                   HorizontalOptions="Center"
                   VerticalOptions="Center"
                   FontAttributes="Bold"></Label>
        </Grid>
    </RefreshView>
</ContentPage>

Views.RefreshView.xaml.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Conclusion.ViewModels;
using Xamarin.Forms;
using Xamarin.Forms.Xaml;
using Conclusion.Models;

namespace Conclusion.Views
{
    [XamlCompilation(XamlCompilationOptions.Compile)]
    public partial class RefreshView : ContentPage
    {
        private RefreshViewModel vm;
        private bool isPageOn;//控制页面计时器Timer,当进入Page时,Timer打开,离开Page时,Timer关闭
        public Command CurrentItemChanged { get; set; }

        Queue<Refresh> bannerQueue;
        public RefreshView()
        {
            InitializeComponent();
            BindingContext = vm = new RefreshViewModel();
            //创建banner队列
            bannerQueue = new Queue<Refresh>(vm.Banners);
            //绑定命令,当用户滑动后,更新队列
            CurrentItemChanged = new Command(() => UpdateBannerQueue());
        }

        #region 处理banner自动换页效果
        private bool ScrollBanner()
        {
            //获取要展示的banner
            Refresh exhibitionBanner = bannerQueue.Dequeue();
            //此banner入队列
            bannerQueue.Enqueue(exhibitionBanner);
            //滚动到要展示的banner
            myBanner.ScrollTo(vm.Banners.IndexOf(exhibitionBanner));
            return isPageOn;
        }

        private void UpdateBannerQueue()
        {
            //在mybanner里展示的应该是bannerQueue的最后一个banner,因此当 当前的banner不是应该正在显示的banner时,更新我们的队列
            while (myBanner.CurrentItem as Refresh != bannerQueue.Last())
            {
                bannerQueue.Enqueue(bannerQueue.Dequeue());
            }
            return;
        }
        #endregion

        /// <summary>
        /// 离开页面关闭Timer
        /// </summary>
        protected override void OnDisappearing()
        {
            base.OnDisappearing();
            isPageOn = false;
        }
        /// <summary>
        /// 进入页面开启timer
        /// </summary>
        protected override void OnAppearing()
        {
            base.OnAppearing();
            Device.StartTimer(TimeSpan.FromSeconds(10), ScrollBanner);
            isPageOn = true;
        }
    }
}

录音页

录音页有一点复杂,我会做些讲解。

值得一提的是,在RecorderView中,我并没有适用RecorderViewModel,因为感觉没必要,不过大家完全可以写一个RecorderViewModel,将按键逻辑绑定到Commad上

Interfaces.IRecorder.cs

using System;
using System.Collections.Generic;
using System.Text;

namespace Conclusion.Interfaces
{
    public interface IRecorder
    {
        void GetRecord();
        void StopRecord();
    }
}

建立IRecorder接口,让Xamarin.Android能够拓展IRecoder

.Android. RecorderForAndroid.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using Android.App;
using Android.Content;
using Android.Media;
using Android.OS;
using Android.Runtime;
using Android.Views;
using Android.Widget;
using Conclusion.Interfaces;
using Java.IO;
using MediaDemo.Droid;
using Xamarin.Forms;
using Conclusion.Droid;

[assembly:Dependency(typeof(RecorderForAndroid))]
namespace Conclusion.Droid
{
    #region 用安卓原生Media实现录音功能
    class RecorderForAndroid : IRecorder
    {
        private AudioRecord recorder = null;
        private ThreadStart recordingThreadStart = null;
        private Thread recordingThread = null;
        private int SampleRate = 8000;
        private bool isRecording = false;
        private TimeSpan maxRecordTime = TimeSpan.FromSeconds(15);
        private DateTime startTime;
        public void GetRecord()
        {
            //Mono:单声道采集样本,8000HZ
            int minBufferSize = AudioRecord.GetMinBufferSize(SampleRate, ChannelIn.Mono, Android.Media.Encoding.Pcm16bit);
            recorder = new AudioRecord(AudioSource.Mic, SampleRate, ChannelIn.Mono,
                Android.Media.Encoding.Pcm16bit, minBufferSize);
            recorder.StartRecording();
            System.Diagnostics.Debug.WriteLine("开始录制");
            recordingThreadStart = new ThreadStart(StartRecord);
            recordingThread = new Thread(recordingThreadStart);
            isRecording = true;

            recordingThread.Start();
            startTime = DateTime.Now;
            System.Diagnostics.Debug.WriteLine("录音线程开始");
        }

        public void StopRecord()
        {
            if (recorder != null)
            {
                isRecording = false;
                recorder.Stop();
                recorder.Release();
                recorder = null;
                recordingThread = null;
                System.Diagnostics.Debug.WriteLine("已停止录音");

                #region 将录制的PCM文件转换成Wav文件
                string musicDir = Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryMusic).AbsolutePath;
                string inFilePath = musicDir + "/voice8K16bitmono.pcm";
                string outFilePath = musicDir + "/Demo.Wav";
                PcmToWav pcm2WavTool = new PcmToWav(SampleRate, ChannelIn.Mono, Android.Media.Encoding.Pcm16bit);
                pcm2WavTool.transferPcmToWav(inFilePath, outFilePath);
                #endregion
            }
        }

        int bufferElementsToRec = 512; // want to play 1024 since 2 bytes we use only 512;
        int bytesPerElement = 2; //2 bytes in 16bits format; 用short采集的数据,一个short占两个字节

        private void StartRecord()
        {
            //强制停止,这里应该发送消息给UI,表明录音结束,从而更改图标,不过我太懒了,没有实现,就注释掉了
            /*if(startTime + maxRecordTime >= DateTime.Now)
            {
                StopRecord();
            }*/

            //获取Android Music文件根目录
            string musicDir = Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryMusic).AbsolutePath;
            //PCM录音文件路径
            string myPath = musicDir + "/voice8K16bitmono.pcm";

            //初始化shortData Buffer
            short[] sData = new short[bufferElementsToRec];
            FileOutputStream os = null;
            try
            {
                os = new FileOutputStream(myPath);
            }
            catch (FileNotFoundException e)
            {
                e.PrintStackTrace();
            }

            while (isRecording)
            {
                recorder.Read(sData, 0, bufferElementsToRec);
                System.Diagnostics.Debug.WriteLine("将麦克风采集到的short类型数据读入Buffer" + sData);

                try
                {
                    byte[] bData = ShortToByte(sData);
                    //向PCM文件写入字节流
                    os.Write(bData, 0, bufferElementsToRec * bytesPerElement);
                }
                catch (IOException e)
                {
                    e.PrintStackTrace();
                }
            }

            try
            {
                os.Close();
            }
            catch (IOException e)
            {
                e.PrintStackTrace();
            }
        }

        /// <summary>
        /// 将short类型数据转换成字节类型
        /// </summary>
        /// <param name="sData"></param>
        /// <returns></returns>
        private byte[] ShortToByte(short[] sData)
        {
            int shortArrsize = sData.Length;
            byte[] bytes = new byte[shortArrsize * 2];
            for (int i = 0; i < shortArrsize; i++)
            {
                //低8位
                bytes[i * 2] = (byte)(sData[i] & 0x00FF);
                //高8位
                bytes[i * 2 + 1] = (byte)(sData[i] >> 8);
                sData[i] = 0;
            }

            return bytes;
        }
    }
    #endregion
}

以这种方式录制的声音是.PCM文件,在安卓手机上并不能播放,因此,参考别人的做法将PCM转成了WAV文件。最后千万不要忘记添加
[assembly:Dependency(typeof(RecorderForAndroid))]

.Android.PcmToWav.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using Android.App;
using Android.Content;
using Android.Media;
using Android.OS;
using Android.Runtime;
using Android.Views;
using Android.Widget;
using Java.IO;

namespace MediaDemo.Droid
{
    #region PCM转Wav类
    public class PcmToWav
    {
        /// <summary>
        /// 缓存的音频大小
        /// </summary>
        private int _bufferSize;
        /// <summary>
        /// 采样率
        /// </summary>
        private int _sampleRate;
        /// <summary>
        /// 声道数
        /// </summary>
        private ChannelIn _channel;

        public PcmToWav(int SampleRate, ChannelIn Channel, Android.Media.Encoding Encoding)
        {
            _sampleRate = SampleRate;
            _channel = Channel;
            _bufferSize = AudioRecord.GetMinBufferSize(_sampleRate, _channel, Encoding);
        }

        public void transferPcmToWav(string inFileName, string outFileName)
        {
            FileInputStream inFile;
            FileOutputStream outFile;

            long totalAudioLen;
            long totalDataLen;
            long longSampleRate = _sampleRate;
            int channels = _channel == ChannelIn.Mono ? 1 : 2;
            long byteRate = 16 * _sampleRate * channels / 8;
            byte[] data = new byte[_bufferSize];
            try
            {
                inFile = new FileInputStream(inFileName);
                outFile = new FileOutputStream(outFileName);
                totalAudioLen = inFile.Channel.Size();
                totalDataLen = totalAudioLen + 36;
                WriteWaveFileHeader(outFile, totalAudioLen, totalDataLen, longSampleRate, channels, byteRate);

                while (inFile.Read(data) != -1)
                {
                    outFile.Write(data);
                }
                inFile.Close();
                outFile.Close();
            }
            catch (IOException e)
            {
                e.PrintStackTrace();
            }
        }

        private void WriteWaveFileHeader(FileOutputStream outFile, long totalAudioLen, long totalDataLen, long longSampleRate, int channels, long byteRate)
        {
            byte[] header = new byte[44];
            //RIFF header
            header[0] = (byte)'R';
            header[1] = (byte)'I';
            header[2] = (byte)'F';
            header[3] = (byte)'F';
            header[4] = (byte)(totalDataLen & 0xff);
            header[5] = (byte)((totalDataLen >> 8) & 0xff);
            header[6] = (byte)((totalDataLen >> 16) & 0xff);
            header[7] = (byte)((totalDataLen >> 24) & 0xff);
            //WAVE
            header[8] = (byte)'W';
            header[9] = (byte)'A';
            header[10] = (byte)'V';
            header[11] = (byte)'E';
            // 'fmt ' chunk
            header[12] = (byte)'f';
            header[13] = (byte)'m';
            header[14] = (byte)'t';
            header[15] = (byte)' ';
            // 4 bytes: size of 'fmt ' chunk
            header[16] = 16;
            header[17] = 0;
            header[18] = 0;
            header[19] = 0;
            // format = 1
            header[20] = 1;
            header[21] = 0;
            header[22] = (byte)channels;
            header[23] = 0;
            header[24] = (byte)(longSampleRate & 0xff);
            header[25] = (byte)((longSampleRate >> 8) & 0xff);
            header[26] = (byte)((longSampleRate >> 16) & 0xff);
            header[27] = (byte)((longSampleRate >> 24) & 0xff);
            header[28] = (byte)(byteRate & 0xff);
            header[29] = (byte)((byteRate >> 8) & 0xff);
            header[30] = (byte)((byteRate >> 16) & 0xff);
            header[31] = (byte)((byteRate >> 24) & 0xff);
            // block align
            header[32] = (byte)(2 * 16 / 8);
            header[33] = 0;
            // bits per sample
            header[34] = 16;
            header[35] = 0;
            //data
            header[36] = (byte)'d';
            header[37] = (byte)'a';
            header[38] = (byte)'t';
            header[39] = (byte)'a';
            header[40] = (byte)(totalAudioLen & 0xff);
            header[41] = (byte)((totalAudioLen >> 8) & 0xff);
            header[42] = (byte)((totalAudioLen >> 16) & 0xff);
            header[43] = (byte)((totalAudioLen >> 24) & 0xff);
            outFile.Write(header, 0, 44);
        }
    }
    #endregion
}

这个类就可以当做工具以后要用直接拿来使用了。

RecorderView.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:d="http://xamarin.com/schemas/2014/forms/design"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:circlebutton="clr-namespace:ButtonCircle.FormsPlugin.Abstractions;assembly=ButtonCircle.FormsPlugin.Abstractions"
             mc:Ignorable="d"
             x:Class="Conclusion.Views.RecorderView">
    <!--这一页展示基于Android原生和AudioRecorder的录音功能-->
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"></RowDefinition>
            <RowDefinition></RowDefinition>
            <RowDefinition Height="Auto"></RowDefinition>
            <RowDefinition></RowDefinition>
        </Grid.RowDefinitions>
        
        <Label 
            Margin="0,10,0,0"
            Grid.Row="0"
            Text="这里是基于安卓原生的录音"
            HorizontalOptions="Center"></Label>
        <Frame 
            Grid.Row="1"
            CornerRadius="10"
            HasShadow="True"
            Margin="10">
            <!--md-mic-->
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition></RowDefinition>
                    <RowDefinition Height="Auto"></RowDefinition>
                </Grid.RowDefinitions>
                <circlebutton:CircleButton 
                    FontIcon="Material"
                    Icon="md-mic-none" 
                    FontSize="50" TextColor="Black" 
                    HeightRequest="70" WidthRequest="70" 
                    BorderThickness="1" BorderColor="Black" 
                    BackgroundColor="WhiteSmoke"
                    Clicked="RecordMovement1"
                    HorizontalOptions="Center"
                    VerticalOptions="Center"
                    Grid.Row="0">
                </circlebutton:CircleButton>
                <Label
                    x:Name="pathOne"
                    Grid.Row="1"
                    Text="保存在emulate/0/Music/Demo.wav中"
                    HorizontalOptions="Center"
                    Opacity="0"></Label>
            </Grid>
            
        </Frame>

        <Label 
            Grid.Row="2"
            Text="这里是基于AudioRecorder的录音"
            HorizontalOptions="Center"></Label>

        <Frame 
            Grid.Row="3"
            CornerRadius="10"
            HasShadow="True"
            Margin="10">
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition></RowDefinition>
                    <RowDefinition Height="Auto"></RowDefinition>
                </Grid.RowDefinitions>
                <circlebutton:CircleButton 
                    FontIcon="Material"
                    Icon="md-mic-none" 
                    FontSize="50" TextColor="Black" 
                    HeightRequest="70" WidthRequest="70" 
                    BorderThickness="1" BorderColor="Black" 
                    BackgroundColor="WhiteSmoke"
                    Clicked="RecordMovement2"
                    HorizontalOptions="Center"
                    VerticalOptions="Center"
                    Grid.Row="0">
                </circlebutton:CircleButton>
                <Label
                    x:Name="pathTwo"
                    Grid.Row="1"
                    Text="保存在emulate/0/Music/Myfolder/Demo.wav中"
                    HorizontalOptions="Center"
                    Opacity="0"></Label>
            </Grid>
        </Frame>
    </Grid>
</ContentPage>

RecorderView.xaml.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Conclusion.Interfaces;
using Xamarin.Forms;
using Plugin.AudioRecorder;
using Xamarin.Forms.Xaml;
using ButtonCircle;
using Conclusion.Utils;

namespace Conclusion.Views
{
    [XamlCompilation(XamlCompilationOptions.Compile)]
    public partial class RecorderView : ContentPage
    {
        private AudioRecorderNuGet audioRecorderService;
        public RecorderView()
        {
            InitializeComponent();
            audioRecorderService = new AudioRecorderNuGet();
        }

        #region 安卓原生录音示例
        private void RecordMovement1(object sender, EventArgs e)
        {
            ButtonCircle.FormsPlugin.Abstractions.CircleButton recordImage = sender as ButtonCircle.FormsPlugin.Abstractions.CircleButton;
            if(recordImage.Icon.ToString().Contains("md-mic-none"))
            {
                DependencyService.Get<IRecorder>().GetRecord();
                recordImage.Icon = "md-mic";
                pathOne.Opacity = 0;        
            }
            else
            {
                DependencyService.Get<IRecorder>().StopRecord();
                recordImage.Icon = "md-mic-none";
                pathOne.Opacity = 1;
            }
        }
        #endregion
        #region AudioRecorder示例
        private async void RecordMovement2(object sender, EventArgs e)
        {
            ButtonCircle.FormsPlugin.Abstractions.CircleButton recordImage = sender as ButtonCircle.FormsPlugin.Abstractions.CircleButton;
            if (recordImage.Icon.ToString().Contains("md-mic-none"))
            {
                recordImage.Icon = "md-mic";
                pathTwo.Opacity = 0;
                string filePath = await audioRecorderService.GetRecord();
                DependencyService.Get<IFileModifier>().ModifyFileLocation(filePath, "/Music/MyFolder", "/Music/MyFolder/Demo.wav");
            }
            else
            {
                recordImage.Icon = "md-mic-none";
                pathTwo.Opacity = 1;
                string filePath = await audioRecorderService.StopRecord();
                DependencyService.Get<IFileModifier>().ModifyFileLocation(filePath, "/Music/MyFolder", "/Music/MyFolder/Demo.wav");
            }
            
        }
        #endregion
    }
}

好了,大家可以看到这里有个RecorderMovement2,它的功能就是基于AudioRecorder实现录音,这里我将AudioRecorder的使用封装成了一个类,方便使用

Utils.AudioRecorderNuget.cs

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Plugin.AudioRecorder;

namespace Conclusion.Utils
{
    #region AudioRecorder用法

    public class AudioRecorderNuGet
    {
        readonly AudioRecorderService recorder;
        public AudioRecorderNuGet()
        {
            recorder = new AudioRecorderService
            {
                StopRecordingAfterTimeout = false,
                TotalAudioTimeout = TimeSpan.FromSeconds(15),
                AudioSilenceTimeout = TimeSpan.FromSeconds(2)
            };
        }

        public async Task<string> GetRecord()
        {
            var audioRecordTask = await recorder.StartRecording();
            var audioFile = await audioRecordTask;
            return audioFile;
        }

        public async Task<string> StopRecord()
        {
            await recorder.StopRecording();
            return recorder.GetAudioFilePath();
        }
        
    }
    #endregion
}

不过AudioRecorder将文件存进0/User/Data中,很多安卓手机无法访问(至少我的VivoNEX S无法访问),因此,我写了一个IFileModifier.cs接口,从而帮助我修改文件路径

Interfaces.IFileModifier.cs

using System;
using System.Collections.Generic;
using System.Text;

namespace Conclusion.Interfaces
{
    public interface IFileModifier
    {
        void ModifyFileLocation(string fromPath, string folderPath, string toPath);
    }
}

.Android.FileModifierForAndroid.cs

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Android;
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using Android.Runtime;
using Android.Support.V4.Content;
using Android.Views;
using Android.Widget;
using Conclusion.Interfaces;
using Xamarin.Forms;
using Conclusion.Droid;


[assembly: Dependency(typeof(FileModifierForAndroid))]
namespace Conclusion.Droid
{
    #region 修改文件路径
    class FileModifierForAndroid : IFileModifier
    {
        /// <summary>
        /// 修改文件路径
        /// </summary>
        /// <param name="fromPath">原本文件路径</param>
        /// <param name="folderPath">文件夹路径</param>
        /// <param name="toPath">现在文件路径</param>
        public void ModifyFileLocation(string fromPath, string folderPath, string toPath)
        {
            string androidExternalStorageRootPath = Android.OS.Environment.ExternalStorageDirectory.AbsolutePath;
            System.Diagnostics.Debug.WriteLine("path:" + androidExternalStorageRootPath);
            folderPath = androidExternalStorageRootPath + folderPath;
            toPath = androidExternalStorageRootPath + toPath;
            //创建文件
            if (!File.Exists(folderPath))
            {
                Directory.CreateDirectory(folderPath);
                File.Create(toPath).Dispose();
            }

            //写入文件
            if (ContextCompat.CheckSelfPermission(Android.App.Application.Context, Manifest.Permission.WriteExternalStorage) == (int)Permission.Granted)
            {
                try
                {
                    if (File.Exists(toPath))
                    {
                        File.Delete(toPath);
                        System.Diagnostics.Debug.WriteLine("删除成功");
                    }
                    File.Copy(fromPath, toPath);
                }
                catch (Exception ex)
                {
                    System.Diagnostics.Debug.WriteLine(ex.Message);
                }
            }
            else
            {
                System.Diagnostics.Debug.WriteLine("不准写入");
            }
        }
    }
    #endregion
}

总结一下,.Android和.Forms基于Dependency进行互动(这是我的理解),方法如下:

  1. .Forms新建ISomeInterface接口 ;
  2. .Android新建SomeClassForAndroid.cs拓展ISomeInterface接口;
  3. 在SomeClassForAndroid文件前加上[assembly:Dependency(typeof(SomeClassForAndroid))];
  4. .Forms用Dependency.get< ISomeInterface >().SomeMethod()调用相应方法;

音乐播放器页

音乐播放器用到了MediaManager库,这里可以参考我的上上篇文章

音乐播放器要实现基本的播放器功能,这里用循环队列实现;以及音乐的选取功能

Views.PlayerView.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:d="http://xamarin.com/schemas/2014/forms/design"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:circlebutton="clr-namespace:ButtonCircle.FormsPlugin.Abstractions;assembly=ButtonCircle.FormsPlugin.Abstractions"
             mc:Ignorable="d"
             Shell.NavBarIsVisible="False"
             x:Class="Conclusion.Views.PlayerView">
     <!--这一页展示基于MediaMannager的播放器功能,以及基于安卓系统的文件选择功能-->

    <Grid BackgroundColor="#81C3D7">
        <Grid.RowDefinitions>
            <RowDefinition></RowDefinition>
            <RowDefinition Height="Auto"></RowDefinition>
        </Grid.RowDefinitions>
        
        <Frame 
            Grid.Row="0"
            Margin="10"
            CornerRadius="10"
            HasShadow="True">
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"></RowDefinition>
                    <RowDefinition></RowDefinition>
                </Grid.RowDefinitions>
                <!--#region 上方选择歌曲按钮-->
                <StackLayout
                    Orientation="Horizontal"
                    HorizontalOptions="End"
                    Grid.Row="0">
                    <circlebutton:CircleButton 
                        FontIcon="Material"
                        Icon="md-playlist-add" 
                        FontSize="30" TextColor="Black" 
                        HeightRequest="40" WidthRequest="40" 
                        BorderThickness="0" BorderColor="Black" 
                        BackgroundColor="WhiteSmoke"
                        Clicked="ChooseMusic">
                    </circlebutton:CircleButton>
                </StackLayout>
                <!--#endregion-->

                <!--#region 中间转盘-->
                <Grid
                    Margin="10"
                    Grid.Row="1">
                    <Grid>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="Auto"></RowDefinition>
                            <RowDefinition Height="Auto"></RowDefinition>
                            <RowDefinition></RowDefinition>
                        </Grid.RowDefinitions>
                        
                        <Grid
                            Grid.RowSpan="3">
                            <Image Source="{Binding SongCover}"></Image>
                        </Grid>
                        
                        <Label
                            Grid.Row="0"
                            Margin="5"
                            HorizontalOptions="Center"
                            Text="{Binding SongName}"></Label>
                        
                        <Label
                            Grid.Row="0"
                            Margin="5"
                            HorizontalOptions="Center"
                            Text="{Binding ArtistName}"></Label>
                        
                        <Image
                            Grid.Row="1"
                            Source="CD.png"></Image>
                    </Grid>
                </Grid>
                <!--#endregion-->
            </Grid>
        </Frame>

        <Frame
            Grid.Row="1"
            Margin="10"
            CornerRadius="10"
            HasShadow="True">
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"></RowDefinition>
                    <RowDefinition Height="*"></RowDefinition>
                </Grid.RowDefinitions>
                <Grid
                    Grid.Row="0">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="Auto"></ColumnDefinition>
                        <ColumnDefinition Width="*"></ColumnDefinition>
                        <ColumnDefinition Width="Auto"></ColumnDefinition>
                    </Grid.ColumnDefinitions>
                    
                    <!--#region 进度条-->
                    <!--Label一定要写宽度,不然闪烁-->
                    <Label
                        Grid.Column="0"
                        WidthRequest="40"
                        HorizontalOptions="Start"
                        VerticalOptions="Center"
                        Text="{Binding NowDuration, StringFormat='\{0:mm\\:ss}'}"></Label>
                    <Slider 
                        Grid.Column="1"
                        Value="{Binding TimeValue}"
                        Maximum="{Binding Duration.TotalMinutes}"
                        Minimum="0"
                        Margin="10"
                        VerticalOptions="Center"
                        ThumbColor="Black"
                        MinimumTrackColor="DarkRed"
                        MaximumTrackColor="DarkCyan"></Slider>
                    <Label
                        Grid.Column="2"
                        WidthRequest="40"
                        HorizontalOptions="Start"
                        VerticalOptions="Center"
                        Text="{Binding Duration, StringFormat='\{0:mm\\:ss}'}"></Label>
                    <!--#endregion-->
                </Grid>
                

                <!--#region 下方操作按钮-->
                <Grid
                    Grid.Row="1">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="Auto"></ColumnDefinition>
                        <ColumnDefinition Width="*"></ColumnDefinition>
                        <ColumnDefinition Width="Auto"></ColumnDefinition>
                    </Grid.ColumnDefinitions>
                    
                    <circlebutton:CircleButton
                        Grid.Column="0"
                        FontIcon="Material"
                        Icon="md-skip-previous" 
                        FontSize="30" TextColor="Black" 
                        HeightRequest="40" WidthRequest="40" 
                        BorderThickness="0" BorderColor="Black" 
                        BackgroundColor="WhiteSmoke"
                        HorizontalOptions="Start"
                        Command="{Binding PreviousCommand}">
                    </circlebutton:CircleButton>
                    
                    <circlebutton:CircleButton 
                        FontIcon="Material"
                        Grid.Column="1"
                        Icon="md-play-arrow" 
                        FontSize="60" TextColor="Black" 
                        HeightRequest="80" WidthRequest="80" 
                        BorderThickness="0" BorderColor="Black" 
                        BackgroundColor="WhiteSmoke"
                        Command="{Binding PlayCommand}"
                        x:Name="PlayControlBtn">
                    </circlebutton:CircleButton>
                    
                    <circlebutton:CircleButton 
                        FontIcon="Material"
                        Grid.Column="2"
                        Icon="md-skip-next" 
                        FontSize="30" TextColor="Black" 
                        HeightRequest="40" WidthRequest="40" 
                        BorderThickness="0" BorderColor="Black" 
                        BackgroundColor="WhiteSmoke"
                        Command="{Binding NextCommand}">
                    </circlebutton:CircleButton>
                </Grid>
                <!--#endregion-->
            </Grid>
        </Frame>
    </Grid>
</ContentPage>

界面先放在这里了,接着我们分两步进行,先实现文件选择,下面定义IFilePick接口

Interfaces.IFilePicker.cs

using System;
using System.Collections.Generic;
using System.Text;

namespace Conclusion.Interfaces
{
    public interface IFilePicker
    {
        void PickFile();
    }
}

.Android.FilePickerForAndroid.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using Android.App;
using Android.Content;
using Android.OS;
using Android.Runtime;
using Android.Views;
using Android.Widget;
using Conclusion.Interfaces;
using Plugin.CurrentActivity;
using Xamarin.Forms;
using Conclusion.Droid;

[assembly:Dependency(typeof(FilePickerForAndroid))]
namespace Conclusion.Droid
{
    class FilePickerForAndroid : IFilePicker
    {
        public void PickFile()
        {
            Intent intent = new Intent();
            intent.SetType("file/*");
            intent.SetAction(Intent.ActionGetContent);

            Activity activity = CrossCurrentActivity.Current.Activity as Activity;

            var chooser = Intent.CreateChooser(intent, "Select File");
            activity.StartActivityForResult(chooser, 0);
            return;
        }
    }
}

这里便用到了CurrentActivity Nuget包,用于获取当前安卓的活动页,然后调用StartActivityForResult方法,在MainActivity中重写OnActivityResult方法:

.Android.MainActivity.cs

protected override void OnActivityResult(int requestCode, [GeneratedEnum] Result resultCode, Intent data)
{
    base.OnActivityResult(requestCode, resultCode, data);
    if (resultCode != Result.Ok)
    {
        return;
    }
    else
    {
        if (data == null)
        {
            return;
        }
        else
        {
            var uri = data.Data as Android.Net.Uri;
            System.Diagnostics.Debug.WriteLine("uri:" + uri);
            //发送AddMusic消息
            MessagingCenter.Send(new MessageCenter(), "AddMusic", uri.ToString());
        }
    }
}

当选择文件后,我们将“AddMusic”这个信息通过MessagingCenter发送给MessageCenter()类,发送的参数是选取的文件的Uri。对于MessageCenter(),我把他设定为我的消息中心,页面与页面,平台与平台的信息交互都可以通过MessagingCenter来实现。下面是MessageCenter的定义

Utils.MessageCenter.cs

using System;
using System.Collections.Generic;
using System.Text;

namespace Conclusion.Utils
{
    public class MessageCenter
    {
    }
}

对,没错,他什么都没有,他就像个工具人,我只将它作为信息中心。好,接下来,就可以为我们的PlayerView写ViewModel了

ViewModels.PlayerViewModel.cs

using System;
using System.Collections.Generic;
using System.Text;
using Conclusion.Models;
using Conclusion.Services;
using Xamarin.Forms;
using MediaManager;
using Conclusion.Views;
using System.Threading.Tasks;
using Conclusion.Utils;

namespace Conclusion.ViewModels
{
    class PlayerViewModel:BaseViewModel
    {
        #region 变量区
        private readonly MockDataStore playerService;


        public Command PlayCommand { get; set; }
        public Command NextCommand { get; set; }
        public Command PreviousCommand { get; set; }

        
        private TimeSpan _duration;
        private TimeSpan _nowDuration;
        private double _timeValue;
        private string _songName;
        private string _artistName;
        private string _songCover;
        
        public TimeSpan Duration
        {
            get => _duration;
            set
            {
                if (_duration == value)
                    return;
                _duration = value;
                OnPropertyChanged();
            }
        }

        public TimeSpan NowDuration
        {
            get => _nowDuration;
            set
            {
                if (_nowDuration == value)
                    return;
                _nowDuration = value;
                OnPropertyChanged();
            }
        }

        public double TimeValue
        {
            get => _timeValue;
            set
            {
                if (_timeValue == value)
                    return;
                _timeValue = value;
                OnPropertyChanged();
            }
        }

        public string SongName
        {
            get => _songName;
            set
            {
                if (_songName == value)
                    return;
                _songName = value;
                OnPropertyChanged();
            }
        }

        public string ArtistName
        {
            get => _artistName;
            set
            {
                if (_artistName == value)
                    return;
                _artistName = value;
                OnPropertyChanged();
            }
        }

        public string SongCover
        {
            get => _songCover;
            set
            {
                if (_songCover == value)
                    return;
                _songCover = value;
                OnPropertyChanged();
            }
        }
        #endregion 

        private readonly int InitPointerCode = 0;
        private List<Player> playQueue = new List<Player>();
        private Player currentMusic;
        private int currentMusicPointer;
        private bool _pauseFlag;
        private bool nextFlag;
        private bool previousFlag;
        private bool allowReload;

        //PauseFlag通知器
        public bool PauseFlag
        {
            get => _pauseFlag;
            set
            {
                _pauseFlag = value;
                MessagingCenter.Send(new MessageCenter(), "pauseFlagChanged", PauseFlag);
            }
        }

        public PlayerViewModel()
        {
            playerService = App.PlayerStore;
            
            Duration = TimeSpan.FromMinutes(100);

            ButtonHandle();
            LoadPlayList();
            //接受来自MessageCenter的信息——信息标题为“AddMusic”
            MessagingCenter.Subscribe<MessageCenter, string>(this, "AddMusic", AddMusic);
            CrossMediaManager.Current.PositionChanged += CurrentPositionChanged;
            CrossMediaManager.Current.MediaItemFinished += CurrentMediaItemFinished;
        }


        #region 加载音乐以及设置开始位置区
        /// <summary>
        /// 加载音乐,从playerService获取
        /// </summary>
        private async void LoadPlayList()
        {
            try
            {
                //尝试暂停播放器
                await CrossMediaManager.Current.Stop();
                PauseFlag = true;
            }
            catch (Exception ex)
            {
                System.Diagnostics.Debug.WriteLine("LoadPlayList:" + ex);
            }
            finally
            {
                playQueue.Clear();
                var players = await playerService.GetObjsAsync(true);
                foreach (Player player in players)
                {
                    playQueue.Add(player);
                }
                SongName = "您还未播放音乐";
                SetMusicStater(playQueue.Count == 0 ? InitPointerCode : playQueue.Count - 1);
            }
        }

        private void SetMusicStater(int musicIndex)
        {
            currentMusicPointer = musicIndex;
        }

        /// <summary>
        /// 只需要添加音乐的URL就好了,因为CrossMediaManager可以获得音乐的所有信息
        /// </summary>
        /// <param name="arg1"></param>
        /// <param name="arg2"></param>
        private async void AddMusic(MessageCenter arg1, string arg2)
        {
            Player newMusic = new Player
            {
                MusicUrl = arg2
            };
            await playerService.AddObjAsync(newMusic);
            LoadPlayList();
        }
        #endregion

        #region 处理前端按键逻辑区
        //状态转换机以及状态处理机
        private void ButtonHandle()
        {
            //初始状态暂停
            PauseFlag = true;
            PlayCommand = new Command(() => ExecutePlayCommand());
            //不向前
            nextFlag = false;
            NextCommand = new Command(() => ExecuteNextCommand());
            //不向后
            previousFlag = false;
            PreviousCommand = new Command(() => ExecutePreviousCommand());

            //允许重新加载歌曲
            allowReload = true;
        }


        private async void CurrentMediaItemFinished(object sender, MediaManager.Media.MediaItemEventArgs e)
        {
            nextFlag = true;
            await PlayerStateHandler();
        }

        private async void ExecutePreviousCommand()
        {
            previousFlag = true;
            await PlayerStateHandler();
        }

        private async void ExecuteNextCommand()
        {
            nextFlag = true;
            await PlayerStateHandler();
        }

        private async void ExecutePlayCommand()
        {
            if (CrossMediaManager.Current.IsPlaying() == false)
            {
                PauseFlag = false;
            }
            else
            {
                PauseFlag = true;
            }
            await PlayerStateHandler();
        }

        private async Task PlayerStateHandler()
        {
            //如果PlayQueue没有歌,则不做处理
            if (playQueue.Count == 0)
            {
                PauseFlag = true;
                return;
            }
                
            
            System.Diagnostics.Debug.WriteLine(nextFlag);
            System.Diagnostics.Debug.WriteLine(previousFlag);
            //利用循环队列实现播放器
            //按下 上/下首歌优先级最高
            if (nextFlag)
            {
                currentMusicPointer++;
                currentMusicPointer %= playQueue.Count;
                nextFlag = false;
                PauseFlag = false;//切歌后,继续播放
                allowReload = true;//切歌后,允许重新加载本首歌
            }
            else if (previousFlag)
            {
                currentMusicPointer--;
                currentMusicPointer = (currentMusicPointer + playQueue.Count) % playQueue.Count;
                previousFlag = false;
                PauseFlag = false;
                allowReload = true;
            }
            
            System.Diagnostics.Debug.WriteLine(currentMusicPointer);
            System.Diagnostics.Debug.WriteLine(PauseFlag);
            System.Diagnostics.Debug.WriteLine(allowReload);

            if (PauseFlag == false)
            {
                if (allowReload)
                {
                    currentMusic = playQueue[currentMusicPointer];
                    var currentMusicInfo = await CrossMediaManager.Current.Play(currentMusic.MusicUrl);
                    //拉取歌曲信息
                    /*
                     * 事实上,因为有了currentMusic对象,这个对象是网络传给我们的,因此,只需要服务器上有图像就可以了
                     */                  
                    SongName = currentMusicInfo.FileName.Length >= 20 ? currentMusicInfo.FileName.Substring(0, 17) + "..." : currentMusicInfo.FileName;
                }
                else await CrossMediaManager.Current.Play();
            }
            else
            {
                await CrossMediaManager.Current.Pause();
                allowReload = false;
            }
        }
        #endregion

        #region 更新音乐信息区

        /// <summary>
        /// 更新歌曲时间
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void CurrentPositionChanged(object sender, MediaManager.Playback.PositionChangedEventArgs e)
        {
            NowDuration = e.Position;
            //为0:0:0的话,SLider最小值和最大值相等,报错,这么操作就好了
            Duration = CrossMediaManager.Current.Duration == TimeSpan.FromMinutes(0)?TimeSpan.FromMinutes(100):CrossMediaManager.Current.Duration;
            TimeValue = e.Position.TotalMinutes;
        }
        #endregion
    }
}

这样就实现了音乐播放器的功能。接下来,完成整个PlayerView

Views.PlayerView.xaml.cs

using Conclusion.ViewModels;
using MediaManager;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Conclusion.Interfaces;
using Xamarin.Forms;
using Xamarin.Forms.Xaml;
using Conclusion.Utils;

namespace Conclusion.Views
{
    [XamlCompilation(XamlCompilationOptions.Compile)]
    public partial class PlayerView : ContentPage
    {
        public PlayerView()
        {
            InitializeComponent();
            BindingContext = new PlayerViewModel();
            MessagingCenter.Subscribe<MessageCenter, bool>(this, "pauseFlagChanged", HandleIcon);
        }

        private void HandleIcon(MessageCenter arg1, bool arg2)
        {
            if(arg2 == true)
            {
                PlayControlBtn.Icon = "md-play-arrow";
            }
            else
            {
                PlayControlBtn.Icon = "md-pause";
            }
        }

        //文件选择
        private void ChooseMusic(object sender, EventArgs e)
        {
            DependencyService.Get<IFilePicker>().PickFile();
        }

    }
}

个人认为音乐播放器这一页可以很好地为大家展示前后逻辑的分离。

图片展览页

这里主要展示瀑布流布局以及FFimageLoading和CrossMedia的用法

CustomLayout.FlowLayout.cs

(感谢 https://www.cnblogs.com/cjw1115/p/6544544.html 作者)

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
using Xamarin.Forms;

//https://www.cnblogs.com/cjw1115/p/6544544.html
namespace Conclusion.CustomLayout
{
    public class FlowLayout : Layout<View>
    {

        public static readonly BindableProperty ColumnProperty = BindableProperty.Create("Column", typeof(int), typeof(FlowLayout), 1);
        public int Column
        {
            get { return (int)this.GetValue(ColumnProperty); }
            set { SetValue(ColumnProperty, value); }
        }
        private double columnWidth = 0;

        public static readonly BindableProperty RowSpacingProerpty = BindableProperty.Create("RowSpacing", typeof(double), typeof(FlowLayout), 0.0);
        public double RowSpacing
        {
            get { return (double)this.GetValue(RowSpacingProerpty); }
            set { SetValue(RowSpacingProerpty, value); }
        }
        public static readonly BindableProperty ColumnSpacingProerpty = BindableProperty.Create("ColumnSpacing", typeof(double), typeof(FlowLayout), 0.0);
        public double ColumnSpacing
        {
            get { return (double)this.GetValue(ColumnSpacingProerpty); }
            set { SetValue(ColumnSpacingProerpty, value); }
        }
        protected override void LayoutChildren(double x, double y, double width, double height)
        {

            double[] colHeights = new double[Column];
            double allColumnSpacing = ColumnSpacing * (Column - 1);
            columnWidth = (width - allColumnSpacing) / Column;
            foreach (var item in this.Children)
            {
                var measuredSize = item.Measure(columnWidth, height, MeasureFlags.IncludeMargins);
                int col = 0;
                for (int i = 1; i < Column; i++)
                {
                    if (colHeights[i] < colHeights[col])
                    {
                        col = i;
                    }
                }
                item.Layout(new Rectangle(col * (columnWidth + ColumnSpacing), colHeights[col], columnWidth, measuredSize.Request.Height));


                colHeights[col] += measuredSize.Request.Height + RowSpacing;
            }
        }
        private double _maxHeight;
        /// <summary>
        /// 计算父元素需要的空间大小
        /// </summary>
        /// <param name="widthConstraint">可供布局的宽度</param>
        /// <param name="heightConstraint">可供布局的高度</param>
        /// <returns>实际需要的布局大小</returns>
        protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint)
        {
            double[] colHeights = new double[Column];
            double allColumnSpacing = ColumnSpacing * (Column - 1);
            columnWidth = (widthConstraint - allColumnSpacing) / Column;
            foreach (var item in this.Children)
            {
                var measuredSize = item.Measure(columnWidth, heightConstraint, MeasureFlags.IncludeMargins);
                int col = 0;
                for (int i = 1; i < Column; i++)
                {
                    if (colHeights[i] < colHeights[col])
                    {
                        col = i;
                    }
                }
                colHeights[col] += measuredSize.Request.Height + RowSpacing;
            }
            _maxHeight = colHeights.OrderByDescending(m => m).First();
            return new SizeRequest(new Size(widthConstraint, _maxHeight));
        }

        public static readonly BindableProperty ItemTemplateProperty = BindableProperty.Create("ItemTemplate", typeof(DataTemplate), typeof(FlowLayout), null);
        public DataTemplate ItemTemplate
        {
            get { return (DataTemplate)this.GetValue(ItemTemplateProperty); }
            set { SetValue(ItemTemplateProperty, value); }
        }
        public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create("ItemsSource", typeof(IList), typeof(FlowLayout), null, propertyChanged: ItemsSource_PropertyChanged);
        public IList ItemsSource
        {
            get { return (IList)this.GetValue(ItemsSourceProperty); }
            set { SetValue(ItemsSourceProperty, value); }
        }

        private static void ItemsSource_PropertyChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var flowLayout = (FlowLayout)bindable;
            var newItems = newValue as IList;
            var oldItems = oldValue as IList;
            var oldCollection = oldValue as INotifyCollectionChanged;
            if (oldCollection != null)
            {
                oldCollection.CollectionChanged -= flowLayout.OnCollectionChanged;
            }

            if (newValue == null)
            {
                return;
            }

            if (newItems == null)
                return;
            if (oldItems == null || newItems.Count != oldItems.Count)
            {
                flowLayout.Children.Clear();
                for (int i = 0; i < newItems.Count; i++)
                {
                    var child = flowLayout.ItemTemplate.CreateContent();
                    ((BindableObject)child).BindingContext = newItems[i];
                    flowLayout.Children.Add((View)child);
                }

            }

            var newCollection = newValue as INotifyCollectionChanged;
            if (newCollection != null)
            {
                newCollection.CollectionChanged += flowLayout.OnCollectionChanged;
            }

            flowLayout.UpdateChildrenLayout();
            flowLayout.InvalidateLayout();
        }


        protected override void OnBindingContextChanged()
        {
            base.OnBindingContextChanged();
        }

        private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
           
            if (e.OldItems != null)
            {
                this.Children.RemoveAt(e.OldStartingIndex);
                this.UpdateChildrenLayout();
                this.InvalidateLayout();
            }

            if (e.NewItems == null)
            {
                return;
            }
            //System.Diagnostics.Debug.WriteLine("OncollectionChanged:"+e.NewItems.Count);

            for (int i = 0; i < e.NewItems.Count; i++)
            {

                var child = this.ItemTemplate.CreateContent();
                ((BindableObject)child).BindingContext = e.NewItems[i];
                this.Children.Add((View)child);
            }

            this.UpdateChildrenLayout();
            this.InvalidateLayout();
        }
    }
}

首先自定义我们的FlowLayout页,接下来,初始化我们的FFimageloading Nuget

.Android.MainActivity.cs

public override void OnRequestPermissionsResult(int requestCode, string[] permissions, [GeneratedEnum] Android.Content.PM.Permission[] grantResults)
{
   Xamarin.Essentials.Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults);
   //请求权限,添加下面一行即可
   Plugin.Permissions.PermissionsImplementation.Current.OnRequestPermissionsResult(requestCode, permissions, grantResults);
   base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
}

接着初始化CrossMedia以实现照片获取

初始化CrossMedia

在Android.Resources中新建xml文件夹

在这里插入图片描述

file_paths.xml
<?xml version="1.0" encoding="utf-8" ?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
  <external-files-path name="my_images" path="Pictures" />
  <external-files-path name="my_movies" path="Movies" />
</paths>
修改Android.Properties.Menifest
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="com.companyname.conclusion" android:installLocation="auto">
	<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="28" />
	<application android:label="Conclusion.Android">
    
    <provider 
      android:name="android.support.v4.content.FileProvider"
				android:authorities="${applicationId}.fileprovider"
				android:exported="false"
				android:grantUriPermissions="true">
      <meta-data android:name="android.support.FILE_PROVIDER_PATHS"
				android:resource="@xml/file_paths"></meta-data>
    </provider>
  </application>
	<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
	<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
	<uses-permission android:name="android.permission.RECORD_AUDIO" />
  
</manifest>

准备工作做足后,写我们的Model

Models.Photo

using System;
using System.Collections.Generic;
using System.Text;

namespace Conclusion.Models
{
    public class Photo:Item
    {
        public string PhotoUrl { get; set; } 
    }
}

ViewModels.PhotoExhibithonViewModel.cs

using System;
using System.Collections.Generic;
using System.Text;
using Xamarin.Forms;
using System.Collections.ObjectModel;
using Conclusion.Models;
using System.Threading.Tasks;
using Conclusion.Services;
using System.Diagnostics;
using Conclusion.Utils;
using Plugin.Media;

namespace Conclusion.ViewModels
{
    class PhotoExhibitionViewModel : BaseViewModel
    {
        private MockDataStore photoService;
        private ObservableCollection<Photo> _photos;

        public Command PhotoExhibitionViewCommand { get; set; }

        public ObservableCollection<Photo> Photos
        {
            get
            {
                return _photos;
            }
            set
            {
                _photos = value;
                OnPropertyChanged();
            }
            
        }

        public PhotoExhibitionViewModel()
        {
            photoService = App.PhotoStore;
            PhotoExhibitionViewCommand = new Command(async () => await ExecutePhotoExhibitionViewCommand());
            if (Photos == null)
            {
                Photos = new ObservableCollection<Photo>();
                PhotoExhibitionViewCommand.Execute(null);
            }

            MessagingCenter.Subscribe<MessageCenter,string>(this, "AddPhoto", AddPhoto);   
            
        }

        private async void AddPhoto(MessageCenter arg1, string arg2)
        {
            System.Diagnostics.Debug.WriteLine("收到信息");
            if (arg2.Equals("Album"))
            {
                if (!CrossMedia.Current.IsPickPhotoSupported)
                {
                    MessagingCenter.Send(new MessageCenter(), "MediaNotAvailable","PickPhoto");
                    return;
                }

                var imageFile = await Plugin.Media.CrossMedia.Current.PickPhotoAsync(new Plugin.Media.Abstractions.PickMediaOptions
                {
                    PhotoSize = Plugin.Media.Abstractions.PhotoSize.Medium
                });

                if (imageFile == null)
                    return;
                //Debug.WriteLine("路径" +imageFile.AlbumPath);
                //Debug.WriteLine("路径" +imageFile.Path);
                await photoService.AddObjAsync(new Photo
                {
                    PhotoUrl = imageFile.Path
                });
            }

            else
            {
                if(!CrossMedia.Current.IsCameraAvailable || !CrossMedia.Current.IsTakePhotoSupported)
                {
                    MessagingCenter.Send(new MessageCenter(), "MediaNotAvailable","TakePhoto");
                    return;
                }

                var imageFile = await CrossMedia.Current.TakePhotoAsync(new Plugin.Media.Abstractions.StoreCameraMediaOptions
                {
                    Directory = "MyImage",
                    SaveToAlbum = true,
                    CompressionQuality = 5,
                    CustomPhotoSize = 100,
                    DefaultCamera = Plugin.Media.Abstractions.CameraDevice.Front
                });
                
                if (imageFile == null)
                    return;
                
                await photoService.AddObjAsync(new Photo
                {
                    PhotoUrl = imageFile.AlbumPath
                });
            }
        }

        private async Task ExecutePhotoExhibitionViewCommand()
        {
            Debug.WriteLine("哈哈哈");
            IsRefresh = true;

            try
            {
                await Task.Delay(520);
                Photos.Clear();
                var photos = await photoService.GetObjsAsync(true);
                //因为用的Add方法
                ObservableCollection<Photo> CachePhotos = new ObservableCollection<Photo>();
                foreach(Photo photo in photos)
                {
                    CachePhotos.Add(photo);
                }
                Photos = CachePhotos;
                //Debug.WriteLine(Photos.Count);
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex);
            }
            finally
            {
                IsRefresh = false;
            }
        }
    }
}

下面开始吐槽


值得注意的是,我这里新建了一个CachePhotos

这是因为,FlowLayout的作者是这样更新ItemSource的
在这里插入图片描述
但是,我们每Add一个元素,都会引起FlowLayout的改变,而新元素又只有1个,因此就算我们Add了很多,最终FlowLayout也只能显示一个元素


下面看看我们的界面

Views.PhotoExhibitionView.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:d="http://xamarin.com/schemas/2014/forms/design"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:customlayout="clr-namespace:Conclusion.CustomLayout"
             xmlns:circlebutton="clr-namespace:ButtonCircle.FormsPlugin.Abstractions;assembly=ButtonCircle.FormsPlugin.Abstractions"
             mc:Ignorable="d"
             xmlns:ffimageloading="clr-namespace:FFImageLoading.Forms;assembly=FFImageLoading.Forms"
             x:Class="Conclusion.Views.PhotoExhibitionView"
             Shell.NavBarIsVisible="False">
    <!--这一页展示瀑布流图片布局以及FFimageLoading的用法,用照相机获取图片及本地图片的获取-->
   
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"></RowDefinition>
            <RowDefinition></RowDefinition>
        </Grid.RowDefinitions>
        <!--#region 顶部照片添加区-->
        <Frame
            Grid.Row="0"
            Margin="10"
            CornerRadius="10"
            HasShadow="True">
            <circlebutton:CircleButton 
                    FontIcon="Material"
                    Icon="md-add-a-photo" 
                    FontSize="30" TextColor="Black" 
                    HeightRequest="40" WidthRequest="40" 
                    BorderThickness="0" BorderColor="Black" 
                    BackgroundColor="WhiteSmoke"
                    Clicked="ChoosePhoto">
            </circlebutton:CircleButton>
        </Frame>
        <!--#endregion-->
            
        <!--#region 照片展示区-->
        <RefreshView
            Grid.Row="1"
            Margin="10"
            IsRefreshing="{Binding IsRefresh}"
            Command="{Binding PhotoExhibitionViewCommand}">
            <ScrollView>
                <customlayout:FlowLayout
                    ItemsSource="{Binding Photos}"
                    Column="2"
                    RowSpacing="10"
                    ColumnSpacing="10">
                    <customlayout:FlowLayout.ItemTemplate>
                        <DataTemplate>
                            <Grid
                                BackgroundColor="Black"
                                Padding="5">
                                <ffimageloading:CachedImage
                                    Source="{Binding PhotoUrl}"
                                    LoadingPlaceholder="notFound.jpg"
                                    ErrorPlaceholder="notFound.jpg">
                                    
                                </ffimageloading:CachedImage>
                            </Grid>
                        </DataTemplate>
                    </customlayout:FlowLayout.ItemTemplate>
                    
                </customlayout:FlowLayout>
            
            </ScrollView>
        </RefreshView>
        <!--#endregion-->
    </Grid>
    
</ContentPage>

这里说一下为什么用FFimageLoading。
FFimageLoading可以加快图片加载速率,有效地管理内存,让图片展示得更加完美,当然,大家可以试试单纯的Image标签(手动滑稽)

Views.PhotoExhibitionView.xaml.cs

using Conclusion.Utils;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Conclusion.ViewModels;
using Xamarin.Forms;
using Xamarin.Forms.Xaml;

namespace Conclusion.Views
{
    [XamlCompilation(XamlCompilationOptions.Compile)]
    public partial class PhotoExhibitionView : ContentPage
    {
        public PhotoExhibitionView()
        {
            InitializeComponent();
            BindingContext = new PhotoExhibitionViewModel();
            
            MessagingCenter.Subscribe<MessageCenter,string>(this, "MediaNotAvailable", HandleError);
        }

        private void HandleError(MessageCenter arg1, string arg2)
        {
            if (arg2.Equals("PickPhoto"))
            {
                DisplayAlert("失败", "相册不可用", "确认");
            }
            else
            {
                DisplayAlert("失败","相机不可用","确认");
            }
        }

        private async void ChoosePhoto(object sender, EventArgs e)
        {
            string choice = await DisplayActionSheet("添加照片", "取消", null, "相册", "相机");
            System.Diagnostics.Debug.WriteLine(choice);
            if(choice == "取消")
            {
                return;
            }
            choice = choice == "相册" ? "Album" : "Camera";
            MessagingCenter.Send(new MessageCenter(), "AddPhoto", choice);
        }
    }
}

.Android.MainActivity.cs完整版

using System;

using Android.App;
using Android.Content.PM;
using Android.Runtime;
using Android.Views;
using Android.Widget;
using Android.OS;
using Xamarin.Forms;
using Android.Support.V4.Content;
using Android;
using Android.Support.V4.App;
using ButtonCircle;
using ButtonCircle.FormsPlugin.Droid;
using MediaManager;
using Plugin.CurrentActivity;
using Android.Content;
using Conclusion.Utils;
using Xamarin.Forms.Platform.Android;
using FFImageLoading.Forms.Platform;

namespace Conclusion.Droid
{
    [Activity(Label = "Conclusion", Icon = "@mipmap/icon", Theme = "@style/MainTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)]
    public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity
    {
        protected override void OnCreate(Bundle savedInstanceState)
        {
            

            TabLayoutResource = Resource.Layout.Tabbar;
            ToolbarResource = Resource.Layout.Toolbar;

            base.OnCreate(savedInstanceState);
            //包的初始化
            NugetInit(savedInstanceState);
            //布局的初始化
            LayOutInit();
            Xamarin.Essentials.Platform.Init(this, savedInstanceState);
            global::Xamarin.Forms.Forms.Init(this, savedInstanceState);
            LoadApplication(new App());

            //写一个函数来提供权限
            CheckPermisson();
            
        }

        private void LayOutInit()
        {
            var newUiOptions = (int)SystemUiFlags.LayoutStable;
            newUiOptions |= (int)SystemUiFlags.LayoutFullscreen;
            //newUiOptions |= (int)SystemUiFlags.HideNavigation;
            Window.SetStatusBarColor(Android.Graphics.Color.Argb(0, 0, 0, 0));
            newUiOptions |= (int)SystemUiFlags.ImmersiveSticky;
            Window.DecorView.SystemUiVisibility = (StatusBarVisibility)newUiOptions;
        }

        private void NugetInit(Bundle savedInstanceState)
        {
            Forms.SetFlags("CollectionView_Experimental");
            CrossCurrentActivity.Current.Init(this, savedInstanceState);
            ButtonCircleRenderer.Init();
            CachedImageRenderer.Init(enableFastRenderer: true);
            CachedImageRenderer.InitImageViewHandler();
            
            CrossMediaManager.Current.Init(this);
        }

        public override void OnRequestPermissionsResult(int requestCode, string[] permissions, [GeneratedEnum] Android.Content.PM.Permission[] grantResults)
        {
            Xamarin.Essentials.Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults);
            //请求权限
            Plugin.Permissions.PermissionsImplementation.Current.OnRequestPermissionsResult(requestCode, permissions, grantResults);
            base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
        }

        private void CheckPermisson()
        {
            //检查录音权限
            if (ContextCompat.CheckSelfPermission(this, Manifest.Permission.RecordAudio) != Permission.Granted)
            {
                ActivityCompat.RequestPermissions(this, new string[] { Manifest.Permission.RecordAudio }, 1);
            }
            //检查外部存储 读写权限
            if (ContextCompat.CheckSelfPermission(this, Manifest.Permission.ReadExternalStorage) != Permission.Granted ||
                ContextCompat.CheckSelfPermission(this, Manifest.Permission.WriteExternalStorage) != Permission.Granted)
            {
                ActivityCompat.RequestPermissions(this, new string[] {Manifest.Permission.ReadExternalStorage,
                    Manifest.Permission.WriteExternalStorage}, 1);
            }

        }


        protected override void OnActivityResult(int requestCode, [GeneratedEnum] Result resultCode, Intent data)
        {
            base.OnActivityResult(requestCode, resultCode, data);
            if (resultCode != Result.Ok)
            {
                return;
            }
            else
            {
                if (data == null)
                {
                    return;
                }
                else
                {
                    var uri = data.Data as Android.Net.Uri;
                    System.Diagnostics.Debug.WriteLine("uri:" + uri);
                    //发送AddMusic消息
                    MessagingCenter.Send(new MessageCenter(), "AddMusic", uri.ToString());
                }
            }
        }
    }
}

欢迎交流

Xamarin开发群:906556968
QQ:1972353869

  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值