安卓多媒体(音频录播、传统摄制、增强摄制)

本章介绍App开发常用的一些多媒体处理技术,主要包括:如何录制和播放音频,如何使用传统相机拍照和录像,如何截取视频画面,如何使用增强相机拍照和录像。

音频录播

本节介绍Android对音频的录播操作,内容包括如何使用系统录音机录制音频、如何利用MediaPlayer播放音频、如何使用MediaRecorder录制音频。

使用系统录音机录制音频

手机自带的系统相机,也有自带的系统录音机,录音机对应的意图动作为MediaStore.Audio.Media.RECORD_SOUND_ACTION,只要在调用startActivityForResult之前指定该动作,就会自动跳转到系统的录音机界面。下面便是前往系统录音机的跳转代码例子:

// 下面打开系统自带的录音机
Intent intent = new Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION);
startActivityForResult(intent, RECORDER_CODE); // 跳到录音机页面

注意上面的RECORDER_CODE是自定义的一个常量值,表示录音来源,目的是在onActivityResult方法中区分唯一的请求码。接着重写活动页面的onActivityResult方法,添加以下的回调代码获取录制好的音频:

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
    super.onActivityResult(requestCode, resultCode, intent);
    if (resultCode==RESULT_OK && requestCode==RECORDER_CODE){
        mAudioUri = intent.getData(); // 获得录制好的音频uri
        String filePath = String.format("%s/%s.mp3",
                getExternalFilesDir(Environment.DIRECTORY_MUSIC), "audio_"+ DateUtil.getNowDateTime());
        FileUtil.saveFileFromUri(this, mAudioUri, filePath); // 保存为临时文件
        tv_audio.setText("录制完成的音频地址为:"+mAudioUri.toString());
        iv_audio.setVisibility(View.VISIBLE);
    }
}

从以上代码可知,录制完的音频路径就在返回意图的getData当中,那么怎样验证这个路劲保存的是音频呢?当然是听听该音频能否正常播放就对了。所谓好事成双,既有录音机,又有收音机,音频自然由系统自带的收音机播放了。若想自动跳转到收音机界面,关键是把数据类型设置为音频,系统才知晓原来是要打开音频,这活还是交给收音机吧。打开系统收音机的跳转代码如下:

// 下面打开系统自带的收音机
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(mAudioUri, "audio/*"); // 类型为音频
startActivity(intent); // 跳到收音机页面

接下来通过实验来看录音与播音的完整过程,点击“打开录音机”按钮之后,跳转到如下图所示的录音机界面。
在这里插入图片描述
点击底部的圆形按钮开始录音,稍等几秒再次点击该按钮结束录音,此时屏幕底部弹出如下图所示的选择对话框。
在这里插入图片描述
点击选择对话框中的“使用此录音”选线,回到测试App界面,如下图所示,可见回调代码成功获得刚录制得音频路径。
点击页面上的三角播放按钮,跳转到如下图的收音机界面,同时收音机开始播放音频。
在这里插入图片描述

利用MediaPlayer播放音频

尽管让App跳转到收音机界面就能播放音频,但是通常App都不希望用户离开自身页面,何况播音本来仅是一个小功能,完全可以一边播放音频一边操作界面。若要在App内部自己播音,便用到了媒体播放器MediaPlayer,不过在播放音频之前,得先想办法找到音频文件才行。通过内容解析器能够从媒体库查找图片文件,同样也能从媒体库查找音频文件,只要把相关条件换成音频种类就成,例如把媒体库得Uri路径从相册换成音频库,把媒体库的查找结果从相册字段换作音频字段等。为此另外定义并声明音频类型的实体对象,声明代码如下:

private List<AudioInfo> mAudioList = new ArrayList<AudioInfo>(); // 音频列表
private Uri mAudioUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; // 音频库的Uri
private String[] mAudioColumn = new String[]{ // 媒体库的字段名称数组
        MediaStore.Audio.Media._ID, // 编号
        MediaStore.Audio.Media.TITLE, // 标题
        MediaStore.Audio.Media.DURATION, // 播放时长
        MediaStore.Audio.Media.SIZE, // 文件大小
        MediaStore.Audio.Media.DATA}; // 文件路径
private MediaPlayer mMediaPlayer = new MediaPlayer(); // 媒体播放器

接着通过内容解析器系统的音频库,把符合条件的音频记录依次添加到音频列表,下面便是从媒体库加载音频文件列表的代码例子:

// 加载音频列表
private void loadAudioList() {
    mAudioList.clear(); // 清空音频列表
    // 通过内容解析器查询音频库,并返回结果集的游标。记录结果按照修改时间降序返回
    Cursor cursor = getContentResolver().query(mAudioUri, mAudioColumn,
            null, null, "date_modified desc");
    if (cursor != null) {
        // 下面遍历结果集,并逐个添加到音频列表。简单起见只挑选前十个音频
        for (int i=0; i<10 && cursor.moveToNext(); i++) {
            AudioInfo audio = new AudioInfo(); // 创建一个音频信息对象
            audio.setId(cursor.getLong(0)); // 设置音频编号
            audio.setTitle(cursor.getString(1)); // 设置音频标题
            audio.setDuration(cursor.getInt(2)); // 设置音频时长
            audio.setSize(cursor.getLong(3)); // 设置音频大小
            audio.setPath(cursor.getString(4)); // 设置音频路径
            mAudioList.add(audio); // 添加至音频列表
        }
        cursor.close(); // 关闭数据库游标
    }
}

找到若干音频文件之后,还要设法利用MediaPlayer来播音。MediaPlayer顾名思义叫作媒体播放器,它既能播放音频也能播放视频,其常用方法说明如下:

  • reset:重置播放器。
  • prepare:准备播放。
  • start:开始播放。
  • pause:暂停播放。
  • stop:停止播放。
  • create:创建指定Uri的播放器。
  • setDataSource:设置播放器数据来源的文件路径。create与setDataSource两个方法只需调用一个。
  • setVolume:设置音量。两个参数分别是左声道和右声道的音量,取值0~1。
  • setAudioStreamType:设置音频流的类型。音频流类型的取值说明见下表。
AudioManager类的铃音类型铃声名称说明
STREAM_VOICE_CALL通话音
STREAM_SYSTEM系统音
STREAM_RING铃声来电与收到短信的铃声
STREAM_MUSIC媒体音音乐、视频、游戏等的声音
STREAM_ALARM闹钟音
STREAM_NOTIFICATION通知音
  • setLooping:设置是否循环播放。true表示循环播放,false表示只播放一次。
  • isPlaying:判断是否正在播放。
  • getCurrentPosition:获取当前播放进度所在的位置。
  • getDuration:获取播放时长,单位为毫秒。

MediaPlayer提供的方法虽多,基本的应用场景只有两个:一个是播放指定音频文件,另一个是在退出页面时释放媒体资源。其中播放音频的场景需要经历下列步骤:重置播放器->设置媒体文件路径->准备播放->开始播放。对应的播放代码示例如下:

mMediaPlayer.reset(); // 重置媒体播放器
// mMediaPlayer.setVolume(0.5f, 0.5f); // 设置音量,可选
mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); // 设置音频流的类型为音乐
try {
    mMediaPlayer.setDataSource(audio.getPath()); // 设置媒体数据的文件路径
    mMediaPlayer.prepare(); // 媒体播放器准备就绪
    mMediaPlayer.start(); // 媒体播放器开始播放
} catch (Exception e) {
    e.printStackTrace();
}

如果没把音频放入后台服务中播放,那么在退出活动页面之时应当主动释放媒体资源,以便提高系统运行效率。此时可以重写活动的onDestroy方法,在该方法内部补充下面的操作代码:

if (mMediaPlayer.isPlaying()) { // 是否正在播放
    mMediaPlayer.stop(); // 结束播放
}
mMediaPlayer.release(); // 释放媒体播放器

当然,上述的两个场景之时两种最基础的运用,除此之外,还存在其他业务场合,包括但不限于:实时刷新当前的播放进度、将音频拖动到指定位置再播放、播放完毕之时提醒用户等,详细的演示代码参见AudioPlayActivity.java。下面是使用MediaPlayer播放音频的界面效果。其中左侧展示了刚打开的初始界面,此时App自动查找并罗列最新的音频文件;点击其中一项音频,App便开始播放该音频,同时下方实时显示播放进度如右侧图片所示。
在这里插入图片描述

利用MediaRecorder录制音频

与媒体播放器相对应,Android提供了媒体录制器MediaRecorder,它既能录制音频也能录制视频。使用MediaRecorder可以在当前页面直接录音,而不必跳转到系统自带的录音机界面。MediaRecorder的常用方法说明如下:

OutputFormat类的输出格式格式分类扩展名格式说明
AMR_NB音频.arm窄带格式
AMR_WB音频.arm宽带格式
AAC_ADTS音频.aac高级的音频传输流格式
MPEG_4视频.mp4MPEG4格式
THREE_GPP视频.3gp3GP格式
  • setAudioEncoder:设置音频编码器。音频编码器的取值说明见下表。注意,该方法应在setOutputFormat方法之后执行,否则会抛出异常。
AudioEncoder类的音频编码器说明
AMR_NB窄带编码
AMR_WB宽带编码
AAC低复杂度的高级编码
HE_AAC高效率的高级编码
AAC_ELD高效率的高级编码
  • setAudioSamplingRate:设置音频的采样率,单位为千赫兹(kHz)。AMR_NB格式默认为8kHz,AMR_WB格式默认为16kHz。
  • setAudioChannels:设置音频每秒录制的字节数。数值越大音频越清晰。

MediaRecorder提供的方法虽多,基本的应用场景只有两个:一个是开始录制媒体文件,另一个是停止录制媒体文件。其中录制音频的场景需要经历下列步骤:重置录制器->设置媒体文件的路径->准备录制->开始录制,对应的录制代码示例如下:

// 获取本次录制的媒体文件路径
mRecordFilePath = MediaUtil.getRecordFilePath(this, "RecordAudio", ".amr");
// 下面是媒体录制器的处理代码
mMediaRecorder.reset(); // 重置媒体录制器
mMediaRecorder.setOnInfoListener(this); // 设置媒体录制器的信息监听器
mMediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); // 设置音频源为麦克风
mMediaRecorder.setOutputFormat(mOutputFormat); // 设置媒体的输出格式。该方法要先于setAudioEncoder调用
mMediaRecorder.setAudioEncoder(mAudioEncoder); // 设置媒体的音频编码器
mMediaRecorder.setMaxDuration(mDuration * 1000); // 设置媒体的最大录制时长
mMediaRecorder.setOutputFile(mRecordFilePath); // 设置媒体文件的保存路径
try {
    mMediaRecorder.prepare(); // 媒体录制器准备就绪
    mMediaRecorder.start(); // 媒体录制器开始录制
} catch (Exception e) {
    e.printStackTrace();
}

至于停止录制操作,直接调用stop方法即可。当然,在退出活动页面之时,还需调用release方法释放录制资源。注意到上述的录制代码引用了若干变量,包括输出格式mOutputFormat、音频编码器mAudioEncoder、最大录制时长mDuration等,这些参数决定了音频文件的音效质量和文件大小,详细的演示例子参见代码MediaRecorderActivity.java。
运行测试App,保持默认的录制参数,点击“开始录制”按钮,正在录音的界面如下图左侧所示;稍等片刻录音完成的界面如下图右侧所示,此时成功保存录制好的音频文件,点击下方的三角播放按钮,就能通过MediaPlayer播音了。
在这里插入图片描述

传统摄制

本节介绍Android对照片和视频的传统摄制操作,内容包括如何使用系统相机拍摄照片(含缩略图和原始图两种方式)、如何使用系统摄像机录制视频、如何利用视频视图与媒体控制条播放视频、如何通过媒体检索工具截取视频画面。

使用系统相机拍摄照片

俗话说“眼睛是心灵的窗户”,那么摄像头便是手机的窗户了,一部手机美不美,很大程度上要看它的摄像头,因为好的摄像头才能拍摄出美丽的照片。对于手机拍照的App开发而言,则有两种实现方式:一种通过Camera工具联合表面视图SurfaceView自行规划编码细节;另一种是借助系统相机自动拍照。考虑到多数场景对图片并无特殊要求,因而使用系统相机更加方便快捷。
调用系统相机的方式也有初级与高级之分,倘若仅仅想看个大概,那么一张缩略图便已足够。下面便是打开相机的代码例子:

// 下面通过系统相机拍照只能获得缩略图
Intent photoIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); 
startActivityForResult(photoIntent, THUMBNAIL_CODE); // 打开系统相机

注意上面的THUMBNAIL_CODE是自定义的一个常量值,表示缩略图来源,目的是在onActivityResult方法中区分唯一的请求代码。接着重写胡活动页面的onActivityResult方法,添加以下的回调代码获取缩略图对象:

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
	super.onActivityResult(requestCode, resultCode, intent);
	if (RESULT_OK == resultCode && THUMBNAIL_CODE == requestCode) {
		// 缩略图放在返回意图中的data字段,将其取出转成位图对象即可
		Bundle extras = intent.getExtras();
		Bitmap bitmap = (Bitmap)extras.get("data");
		iv_photo.setImageBitmap(bitmap); // 设置图像视图的位图对象
	}
}

运行App,打开系统相册,此时定格的画面如下左图所示。点击屏幕右上角的打勾图标,返回App界面如下图右侧所示,果然显示刚才拍照的缩略图。
在这里插入图片描述
通过系统相机拍照获得缩略图就是这么简单,只是缩略图不够清晰,马马虎虎浏览一下尚可,要看得细致入微确实不能够了。若想得到高清大图,势必采取系统相机得高级用法,为此事先声明一个图片得Uri对象,声明代码如下:

private Uri mImageUri; // 图片的路径对象

接着在打开系统相机之前,传入图片得路径对象,表示拍好得图片保存在这个路径,具体得操作代码如下(注意安卓10得适配处理代码):

// Android10开始必须由系统自动分配路径,同时该方式也能自动刷新相册
ContentValues values = new ContentValues();
// 指定图片文件的名称
values.put(MediaStore.Images.Media.DISPLAY_NAME, "photo_"+DateUtil.getNowDateTime());
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg"); // 类型为图像
// 通过内容解析器插入一条外部内容的路径信息
mImageUri = getContentResolver().insert(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
// 下面通过系统相机拍照可以获得原始图
photoIntent.putExtra(MediaStore.EXTRA_OUTPUT, mImageUri);
startActivityForResult(photoIntent, ORIGINAL_CODE); // 打开系统相机

以上的ORIGINAL_CODE依然是自定义得请求代码,表示原始图来源,然后重写活动页面的onActivityResult方法,补充下述的分支处理代码:

if (RESULT_OK == resultCode && ORIGINAL_CODE == requestCode) {
	// 根据指定图片的Uri,获得自动缩小后的位图对象
	Bitmap bitmap = BitmapUtil.getAutoZoomImage(this, mImageUri);
	iv_photo.setImageBitmap(bitmap); // 设置图像视图的位图对象
}

因为之前已经把图片的路径对象传给系统相机了,所以这里可以直接设置图像视图的路径对象,无须再去解析什么包裹信息。
重新运行测试App,打开系统相机后拍照,此时定额的画面如下左图。仍旧点击屏幕右上角的打勾图标,返回App界面如下右图所示,果然成功展示了拍摄的高清大图。
在这里插入图片描述

使用系统摄像机录制视频

与音频类似,通过系统摄像机可以很方便地录制视频,只要指定摄像动作为MediaStore.ACTION_VIDEO_CAPTURE即可。当然,也能事先设定下列的摄像参数:

下面是跳转到系统摄像机的代码例子:

// 声明一个活动结果启动器对象
private ActivityResultLauncher launcher = registerForActivityResult (
	new ActivityResultContracts.TakeVideo(), bitmap -> {
	    tv_video.setText("录制完成的视频地址为:"+mVideoUri.toString());
	    rl_video.setVisibility(View.VISIBLE);
	    if (bitmap == null) {
	        // 获取视频文件的某帧图片
	        bitmap = MediaUtil.getOneFrame(this, mVideoUri, 1000);
	    }
	    iv_video.setImageBitmap(bitmap);
	});

// 开始录制视频
private void takeVideo() {
    // Android10开始必须由系统自动分配路径,同时该方式也能自动刷新相册
    ContentValues values = new ContentValues();
    // 指定图片文件的名称
    values.put(MediaStore.Video.Media.DISPLAY_NAME, "video_"+DateUtil.getNowDateTime());
    values.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4"); // 类型为视频
    // 通过内容解析器插入一条外部内容的路径信息
    mVideoUri = getContentResolver().insert(
            MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
    launcher.launch(mVideoUri);
}

视频录制完成,最好能够预览视频的摄制画面,所以上面代码调用了getOneFrame方法获取视频文件的某帧图片,查看该帧图像即可大致了解视频内容。抽取视频帧图的getOneFrame方法代码如下:

    // 获取视频文件中的某帧图片。pos为毫秒时间
    public static Bitmap getOneFrame(Context ctx, Uri uri, int pos) {
        MediaMetadataRetriever retriever = new MediaMetadataRetriever();
        retriever.setDataSource(ctx, uri); // 将指定Uri设置为媒体数据源
        // 获取指定时间的帧图,注意getFrameAtTime方法的时间单位是微秒
        Bitmap bitmap = retriever.getFrameAtTime(pos * 1000);
        return bitmap;
    }

有了视频文件的Uri之后,就能利用系统自带的播放器观看视频了。同样设置意图动作Intent.ACTION_VIEW,并指定数据类型为视频,以下几行代码即可打开视频播放器:

// 创建一个内容获取动作的意图(准备跳到系统播放器)
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(mVideoUri, "video/*"); // 类型为视频
startActivity(intent); // 打开系统的视频播放器

运行App,点击“打开摄像机”按钮之后,跳转到如下图左侧所示的系统摄像界面,点击界面下方中央的圆形按钮开始录像,稍等几秒再次按下该按钮,或者等待EXTRA_DURATION_LIMIT设定的时长到达,此时摄像结束的界面如下图右侧所示。
在这里插入图片描述
点击录像界面右上角的打勾图标,回到App的演示界面,发现原页面展示了已枯枝视频的快照图像。单击该快照图片表示期望播放视频,即可播放录制的视频。

利用视频视图与媒体控制条播放视频

通过专门的播放器固然能够播放视频,但要离开当前App跳转到播放器界面才行,因为视频播放不算很复杂的功能,人们更希望内嵌在当前App界面,所以Android提供了名为视频视图(VideoView)的播放控件,该控件允许图像视图那样划出一块界面展示视频,同时还支持对视频进行播放控制,为开发者定制视频操作提供了便利。
下面是VideoView的常用方法:

由于VideoView只显示播放界面,没显示控制按钮和进度条,因此在实际开发中需要给她配备媒体控制条MediaController。该控制条支持基本的播放控制操作,包括:显示当前的播放进度、拖动到指定位置播放、暂停播放与恢复播放、查看视频的总时长和已播放时长、对视频做快进或快退操作等。
下面是MediaController的常用方法说明:

  • setMediaPlayer:设置媒体播放器的对象,也就是指定某个VideoView。
  • show:显示媒体控制条。
  • hide:隐藏媒体控制条。
  • isShowing:判断媒体控制条是否正在显示。

将媒体控制条与视频图集成起来的话,一般让媒体控制条固定放在视频视图的底部。此时无须在XML文件中添加MediaController节点,只需要添加VideoView节点,然后在Java代码中将媒体控制条附着于视频视图即可。具体的集成步骤分为下列4步:

  1. 由视频对象调用setVideoURI方法指定视频文件。
  2. 创建一个媒体控制条,并由视频视图对象调用setMediaController方法关联该控制条。
  3. 由控制条对象调用setMediaPlayer方法,将媒体播放器设置为该视频视图。
  4. 调用视频视图对象的start方法,开始播放视频。

接下来实验看看如何通过视频视图播放视频。首先创建测试活动页面,在该页面的XML文件中添加VideoView节点,完整的XML内容如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <Button
        android:id="@+id/btn_choose"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="打开相册播放视频"
        android:textColor="@color/black"
        android:textSize="17sp" />
    <VideoView
        android:id="@+id/vv_content"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
</LinearLayout>

然后往该页面的活动代码补充选视频库之后的回调逻辑,也就是重写registerForActivityResult回调方法,在该方法内部设置视频图的视频路径,关联媒体控制条,再调用时评视图的start方法播放视频。详细的活动页面代码示例如下:

public class VideoPlayActivity extends AppCompatActivity {
    private final static String TAG = "VideoPlayActivity";
    private VideoView vv_content; // 声明一个视频视图对象

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_video_play);
        // 从布局文件中获取名叫vv_content的视频视图
        vv_content = findViewById(R.id.vv_content);
        // 注册一个善后工作的活动结果启动器,获取指定类型的内容
        ActivityResultLauncher launcher = registerForActivityResult(
                new ActivityResultContracts.GetContent(), uri -> {
                    if (uri != null) {
                        playVideo(uri); // 播放视频
                    }
                });
        findViewById(R.id.btn_choose).setOnClickListener(v -> launcher.launch("video/*"));
    }

    private void playVideo(Uri uri) {
        vv_content.setVideoURI(uri); // 设置视频视图的视频路径
        MediaController mc = new MediaController(this); // 创建一个媒体控制条
        vv_content.setMediaController(mc); // 给视频视图设置相关联的媒体控制条
        mc.setMediaPlayer(vv_content); // 给媒体控制条设置相关联的视频视图
        vv_content.start(); // 视频视图开始播放
    }
}

运行测试App,打开初始的视频界面如下图最左侧所示,此时按钮下方没有黑漆漆的一片都是视频视图区域;点击“打开相册播放视频”按钮从视频库选择视频回来,该界面立即开始播放选中的视频,如下图中间图片;在视频区域轻轻点击,此时视频下方弹出一排媒体控制条,如下图最右侧所示,可见媒体控制条上半部分有快进、暂停、快退 3个按钮,下半部分展示了当前播放时长、播放进度条、视频总时长。
在这里插入图片描述

截取视频的某帧画面

不管是系统相册还是视频网站,在某个视频尚未播放的时候都会显示一张预览图片,该图片通常是视频中的某个画面。Android从视频中截取某帧画面,用到了媒体检索工具MediaMetadataRetriever,它的常见方法分别说明如下:

下面是利用MediaMetadataRetriever从视频截取某帧位图的示例代码:

// 获取视频文件中的某帧图片。pos为毫秒时间
public static Bitmap getOneFrame(Context ctx, Uri uri, int pos) {
    MediaMetadataRetriever retriever = new MediaMetadataRetriever();
    retriever.setDataSource(ctx, uri); // 将指定Uri设置为媒体数据源
    // 获取指定时间的帧图,注意getFrameAtTime方法的时间单位是微秒
    Bitmap bitmap = retriever.getFrameAtTime(pos * 1000);
    return bitmap;
}

若要从视频中截取一串时间相邻的画面,则可依据相邻的时间点调用getFrameAtTime方法,依次获得每帧位图再保存到存储卡。连续截取视频画面的示例代码如下:

// 获取视频文件中的图片帧列表。beginPos为毫秒时间,count为待获取的帧数量
public static List<String> getFrameList(Context ctx, Uri uri, int beginPos, int count) {
    String videoPath = uri.toString();
    String videoName = videoPath.substring(videoPath.lastIndexOf("/")+1);
    if (videoName.contains(".")) {
        videoName = videoName.substring(0, videoName.lastIndexOf("."));
    }
    List<String> pathList = new ArrayList<>();
    MediaMetadataRetriever retriever = new MediaMetadataRetriever();
    retriever.setDataSource(ctx, uri); // 将指定Uri设置为媒体数据源
    // 获得视频的播放时长
    String duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
    int dura_int = Integer.parseInt(duration)/1000;
    for (int i=0; i<dura_int-beginPos/1000 && i<count; i++) { // 最多只取前多少帧
        String path = String.format("%s/%s_%d.jpg",
                ctx.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString(), videoName, i);
        if (beginPos!=0 || !new File(path).exists()) {
            // 获取指定时间的帧图,注意getFrameAtTime方法的时间单位是微秒
            Bitmap frame = retriever.getFrameAtTime(beginPos*1000 + i*1000*1000);
            int ratio = frame.getWidth()/500+1;
            Bitmap small = BitmapUtil.getScaleBitmap(frame, 1.0/ratio);
            BitmapUtil.saveImage(path, small); // 把位图保存为图片文件
        }
        pathList.add(path);
    }
    return pathList;
}

运行测试该App,打开视频文件播放一阵后,点击“截取当前帧”按钮,可观察到截取结果如下图左侧所示;再点击“截取后九段”按钮,随后会跳转到各帧画面的列表项,成功截取到视频画面,如下图右侧所示。
在这里插入图片描述

增强摄制

本节介绍Android对相片和视频录制与播放的高级用法,内容包括如何使用增强CameraX库拍摄相片、如何使用增强的CameraX库录制视频、如何使用新型播放器ExoPlayer播放各类视频(网络视频和带字幕视频)。

使用CameraX拍照

Android的SDK一开始就自带了相机工具Camera,从Android 5.0开始又推出了升级版的Camera2,然而不管是初代的Camera还是二代的Camera2,编码过程都比较繁琐,对于新手而言有点艰深。为此谷歌公司再Jetpack库中集成了增强的相机库CameraX,想让相机编码(包括拍照和录像)变得更加方便。CameraX基于Camera2开发,它提供一致易用的API接口,还解决了设备兼容性问题,从而减少了编码工作量。
不管是拍照还是录像,都要在AndroidManifest.xml中添加相机权限,还要添加存储卡访问权限,代码如下:

<!-- 相机 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- 存储卡读写 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

由于CameraX来自Jetpack库,因此要修改模块build.gradle.kts,往dependencies节点添加以下几行配置,表示导入指定版本的CameraX库:

implementation ("androidx.camera:camera-core:1.0.2")
implementation ("androidx.camera:camera-camera2:1.0.2")
implementation ("androidx.camera:camera-lifecycle:1.0.2")
implementation ("androidx.camera:camera-view:1.0.0-alpha32")

使用CameraX拍照之前先要初始化相机,包括界面预览以及参数设定等,具体的初始化步骤说明如下:

  1. 准备一个预览视图对象PreviewView,并添加至当前界面。
  2. 获取相机提供器对象ProcessCameraProvider。
  3. 构建预览对象Preview,指定预览的宽高比例。
  4. 构建摄像头选择器对象CameraSelector,指定使用前置摄像头还是后置摄像头。
  5. 构建图像捕捉器对象ImageCapture,分别设置捕捉模式、旋转角度、宽高比例、闪光模式等拍照参数。
  6. 调用相机提供器对象的bindToLifecyccle方法,把相机选择器、预览视图、图像捕捉绑定到相机提供器。
  7. 调用预览视图对象的setSurfaceProvider方法,设置预览视图的表面提供器。

把上述的初始化步骤串起来,写到一个自定义的相机视图控件中,便形成了以下的CameraX初始化代码:

private Context mContext; // 声明一个上下文对象
private PreviewView mCameraPreview; // 声明一个预览视图对象
private CameraSelector mCameraSelector; // 声明一个摄像头选择器
private Preview mPreview; // 声明一个预览对象
private ProcessCameraProvider mCameraProvider; // 声明一个相机提供器
private ImageCapture mImageCapture; // 声明一个图像捕捉器
private VideoCapture mVideoCapture; // 声明一个视频捕捉器
private ExecutorService mExecutorService; // 声明一个线程池对象
private LifecycleOwner mOwner; // 声明一个生命周期拥有者
private int mCameraMode = MODE_PHOTO; // 0拍照,1录像
private int mCameraType = CameraSelector.LENS_FACING_BACK; // 摄像头类型,默认后置摄像头
private int mAspectRatio = AspectRatio.RATIO_16_9; // 宽高比例。RATIO_4_3表示宽高3比4;RATIO_16_9表示宽高9比16
private int mFlashMode = ImageCapture.FLASH_MODE_AUTO; // 闪光灯模式
private String mMediaDir; // 媒体保存目录

public CameraXView(Context context, AttributeSet attrs) {
    super(context, attrs);
    mContext = context;
    mCameraPreview = new PreviewView(mContext); // 创建一个预览视图
    ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
    mCameraPreview.setLayoutParams(params);
    addView(mCameraPreview); // 把预览视图添加到界面上
    mExecutorService = Executors.newSingleThreadExecutor(); // 创建一个单线程线程池
    mMediaDir = mContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString();
}

// 打开相机
public void openCamera(LifecycleOwner owner, int cameraMode, OnStopListener sl) {
    mOwner = owner;
    mCameraMode = cameraMode;
    mStopListener = sl;
    mHandler.post(() ->  initCamera()); // 初始化相机
}

// 初始化相机
private void initCamera() {
    ListenableFuture future = ProcessCameraProvider.getInstance(mContext);
    future.addListener(() -> {
        try {
            mCameraProvider = (ProcessCameraProvider) future.get();
            resetCamera(); // 重置相机
        } catch (Exception e) {
            e.printStackTrace();
        }
    }, ContextCompat.getMainExecutor(mContext));
}

// 重置相机
private void resetCamera() {
    int rotation = mCameraPreview.getDisplay().getRotation();
    // 构建一个摄像头选择器
    mCameraSelector = new CameraSelector.Builder().requireLensFacing(mCameraType).build();
    // 构建一个预览对象
    mPreview = new Preview.Builder()
            .setTargetAspectRatio(mAspectRatio) // 设置宽高比例
            .build();
    // 构建一个图像捕捉器
    mImageCapture = new ImageCapture.Builder()
            .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) // 设置捕捉模式
            .setTargetRotation(rotation) // 设置旋转角度
            .setTargetAspectRatio(mAspectRatio) // 设置宽高比例
            .setFlashMode(mFlashMode) // 设置闪光模式
            .build();
    if (mCameraMode == MODE_RECORD) { // 录像
        // 构建一个视频捕捉器
        mVideoCapture = new VideoCapture.Builder()
                .setTargetAspectRatio(mAspectRatio) // 设置宽高比例
                .setVideoFrameRate(60) // 设置视频帧率
                .setBitRate(3 * 1024 * 1024) // 设置比特率
                .setTargetRotation(rotation) // 设置旋转角度
                .setAudioRecordSource(MediaRecorder.AudioSource.MIC)
                .build();
    }
    bindCamera(MODE_PHOTO); // 绑定摄像头
    // 设置预览视图的表面提供器
    mPreview.setSurfaceProvider(mCameraPreview.getSurfaceProvider());
}

// 绑定摄像头
private void bindCamera(int captureMode) {
    mCameraProvider.unbindAll(); // 重新绑定前要先解绑
    try {
        if (captureMode == MODE_PHOTO) { // 拍照
            // 把相机选择器、预览视图、图像捕捉器绑定到相机提供器的生命周期
            Camera camera = mCameraProvider.bindToLifecycle(
                    mOwner, mCameraSelector, mPreview, mImageCapture);
        } else if (captureMode == MODE_RECORD) { // 录像
            // 把相机选择器、预览视图、视频捕捉器绑定到相机提供器的生命周期
            Camera camera = mCameraProvider.bindToLifecycle(
                    mOwner, mCameraSelector, mPreview, mVideoCapture);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

// 关闭相机
public void closeCamera() {
    mCameraProvider.unbindAll(); // 解绑相机提供器
    mExecutorService.shutdown(); // 关闭线程池
}

初始化相机后,即可调用图像捕捉器的takePicture方法拍摄照片了,拍照代码示例如下:

private String mPhotoPath; // 照片保存路径
// 获取照片的保存路径
public String getPhotoPath() {
    return mPhotoPath;
}

// 开始拍照
public void takePicture() {
    mPhotoPath = String.format("%s/%s.jpg", mMediaDir, DateUtil.getNowDateTime());
    ImageCapture.Metadata metadata = new ImageCapture.Metadata();
    // 构建图像捕捉器的输出选项
    ImageCapture.OutputFileOptions options = new ImageCapture.OutputFileOptions.Builder(new File(mPhotoPath))
            .setMetadata(metadata).build();
    // 执行拍照动作
    mImageCapture.takePicture(options, mExecutorService, new ImageCapture.OnImageSavedCallback() {
        @Override
        public void onImageSaved(ImageCapture.OutputFileResults outputFileResults) {
            BitmapUtil.notifyPhotoAlbum(mContext, mPhotoPath); // 通知相册来了张新图片
            mStopListener.onStop("已完成拍摄,照片保存路径为"+mPhotoPath);
        }

        @Override
        public void onError(ImageCaptureException exception) {
            mStopListener.onStop("拍摄失败,错误信息为:"+exception.getMessage());
        }
    });
}

然后在App代码中集成新定义的增强相机控件,先在布局文件中添加CameraXView节点,代码如下:

<com.example.chapter14.widget.CameraXView
    android:id="@+id/cxv_preview"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />

再给Java代码补充CameraXView对象的初始化以及拍照动作,其中关键代码示例如下:

private CameraXView cxv_preview; // 声明一个增强相机视图对象
private View v_black; // 声明一个视图对象
private ImageView iv_photo; // 声明一个图像视图对象
private final Handler mHandler = new Handler(Looper.myLooper()); // 声明一个处理器对象

// 初始化相机
private void initCamera() {
    // 打开增强相机,并指定停止拍照监听器
    cxv_preview.openCamera(this, CameraXView.MODE_PHOTO, (result) -> {
        runOnUiThread(() -> {
            iv_photo.setEnabled(true);
            Toast.makeText(this, result, Toast.LENGTH_SHORT).show();
        });
    });
}

// 处理拍照动作
private void dealPhoto() {
    iv_photo.setEnabled(false);
    v_black.setVisibility(View.VISIBLE);
    cxv_preview.takePicture(); // 拍摄照片
    mHandler.postDelayed(() -> v_black.setVisibility(View.GONE), 500);
}

运行App,点击拍照图标,观察到增强相机的拍照效果如下图所示。其中,左图为准备拍照时的预览界面,右图为拍照结束后的观赏界面。
在这里插入图片描述

使用CameraX录像

要通过CameraX事先录像功能的话,初始化相机的步骤与拍照时大小异同,区别在于增加了对视频捕捉器VideoCapture的处理。需要修改的代码主要有三个地方,分别说明如下:

  1. 第一个地方是在build.gradle.kts里补充声明录音权限,完整的权限声明配置如下:
<!-- 相机 -->
<uses-permission android:name="android.permission.CAMERA" /> <
!-- 录音 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> 
<!-- 存储卡读写 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
  1. 第二个地方是在重置相机的resetCamera方法中,构建完图像捕捉器对象后,还要构建视频捕捉器对象,并设置视频的宽高比例、视频帧率、比特率(视频每秒录制的比特数)、旋转角度等录制参数。视频捕捉器的构建代码示例如下:
if (mCameraMode == MODE_RECORD) { // 录像
    // 构建一个视频捕捉器
    mVideoCapture = new VideoCapture.Builder()
            .setTargetAspectRatio(mAspectRatio) // 设置宽高比例
            .setVideoFrameRate(60) // 设置视频帧率
            .setBitRate(3 * 1024 * 1024) // 设置比特率
            .setTargetRotation(rotation) // 设置旋转角度
            .setAudioRecordSource(MediaRecorder.AudioSource.MIC)
            .build();
}
  1. 第三个地方是在绑定摄像头的bindCamera方法中,对于录像操作来说,需要把视频捕捉器绑定到相机提供器绑定到相机提供器的生命周期,而非绑定图像捕捉器。绑定视频捕捉器的代码示例如下:
// 把相机选择器、预览视图、图像捕捉器绑定到相机提供器的生命周期
Camera camera = mCameraProvider.bindToLifecycle(
        mOwner, mCameraSelector, mPreview, mImageCapture);

初始化相机之后,即可调用视频捕捉器的startRecording方法开始录像,或者调用stopRecording方法停止录像。录像代码如下:

private String mVideoPath; // 视频保存路径
private int MAX_RECORD_TIME = 15; // 最大录制时长,默认15秒
// 获取视频的保存路径
public String getVideoPath() {
    return mVideoPath;
}

// 开始录像
public void startRecord(int max_record_time) {
    MAX_RECORD_TIME = max_record_time;
    bindCamera(MODE_RECORD); // 绑定摄像头
    mVideoPath = String.format("%s/%s.mp4", mMediaDir, DateUtil.getNowDateTime());
    VideoCapture.Metadata metadata = new VideoCapture.Metadata();
    // 构建视频捕捉器的输出选项
    VideoCapture.OutputFileOptions options = new VideoCapture.OutputFileOptions.Builder(new File(mVideoPath))
            .setMetadata(metadata).build();
    // 开始录像动作
    mVideoCapture.startRecording(options, mExecutorService, new VideoCapture.OnVideoSavedCallback() {
        @Override
        public void onVideoSaved(VideoCapture.OutputFileResults outputFileResults) {
            mHandler.post(() -> bindCamera(MODE_PHOTO));
            mStopListener.onStop("录制完成的视频路径为"+mVideoPath);
        }

        @Override
        public void onError(int videoCaptureError, String message, Throwable cause) {
            mHandler.post(() -> bindCamera(MODE_PHOTO));
            mStopListener.onStop("录制失败,错误信息为:"+cause.getMessage());
        }
    });
    // 限定时长到达之后自动停止录像
    mHandler.postDelayed(() -> stopRecord(), MAX_RECORD_TIME*1000);
}

// 停止录像
public void stopRecord() {
    mVideoCapture.stopRecording(); // 视频捕捉器停止录像
}

当然,录像功能也要先在布局文件中添加CameraXView节点。为了方便观察当前已录制的时长,还可以在布局文件中添加计时器节点chronometer。接着给Java代码补充CameraXView对象的初始化以及录像动作,其中关键代码示例如下:

private CameraXView cxv_preview; // 声明一个增强相机视图对象
private Chronometer chr_cost; // 声明一个计时器对象
private ImageView iv_record; // 声明一个图像视图对象
private boolean isRecording = false; // 是否正在录像

// 初始化相机
private void initCamera() {
    // 打开增强相机,并指定停止录像监听器
    cxv_preview.openCamera(this, CameraXView.MODE_RECORD, (result) -> {
        runOnUiThread(() -> {
            chr_cost.setVisibility(View.GONE);
            chr_cost.stop(); // 停止计时
            iv_record.setImageResource(R.drawable.record_start);
            iv_record.setEnabled(true);
            isRecording = false;
            Toast.makeText(this, result, Toast.LENGTH_SHORT).show();
        });
    });
}

// 处理录像动作
private void dealRecord() {
    if (!isRecording) {
        iv_record.setImageResource(R.drawable.record_stop);
        cxv_preview.startRecord(15); // 开始录像
        chr_cost.setVisibility(View.VISIBLE);
        chr_cost.setBase(SystemClock.elapsedRealtime()); // 设置计时器的基准时间
        chr_cost.start(); // 开始计时
        isRecording = !isRecording;
    } else {
        iv_record.setEnabled(false);
        cxv_preview.stopRecord(); // 停止录像
    }
}

运行测试App,打开录像界面的初始效果如下图左图,此时除了预览画面外,界面下方还展示录制按钮。点击录制按钮录像,正在录像的界面如下右图所示,此时录制按钮换成了暂停按钮,其上方也跳动着已录制时长的数字。
在这里插入图片描述

新型播放器ExoPlayer

尽管录制视频的相机工具从经典相机Camera演进到了二代相机Camera2再到增强相机CameraX,然而播放视频仍是老控件MediaPlayer以及封装了MediaPlayer的视频视图,这个MediaPlayer用于播放本地的小视频还可以,如果用它播放网络视频就存在下列问题了:

  1. MediaPlayer不支持一边下载一边播放,必须等视频全部下载完才开始播放。
  2. MediaPlayer不支持视频直播协议,包括MPEG标准的自适应流(Dynamic Adaptive Streaming over HTTP, DASH)、苹果公司的直播流(HTTP Live Streaming, HLS)、微软公司的平滑流(Smooth Streaming)等。
  3. 未加密的视频容易被盗版,如果加密了,MediaPlayer反而无法播放加密视频。

为此Android在新一代的Jetppack库中推出了新型播放ExoPlayer,它的音视频内核依赖于原生的MediaCodec接口,不但能够播放MediaPlayer所支持的任意格式的视频,而且具备以下几点优异特性:

  1. 对于网络视频,允许一边下载一边播放。
  2. 支持三大视频直播协议,包括自适应流(DASH)、直播流(HLS)、平滑流(Smooth Streaming)。
  3. 只支持播放采取Widevine技术加密的网络视频。
  4. 只要提供了对应的字幕文件(srt格式),就支持在播放视频时同步显示字幕。
  5. 支持合并、串联、循环等多种播放方式。

Exoplayer居然能够做这么多事情,简直比MediaPlayer省心多了。当然,因为Exoplayer来自Jetpack库,所以使用之前要先修改build.gradle.kts,添加下面一行依赖配置:

implementation("com.google.android.exoplayer:exoplayer:2.19.1")

Exoplayer的播放界面采用播放器视图StylePlayerView,它的自定义属性分别说明如下:

  • show_buffering:缓冲进度的显示模式,值为never时表示从不显示,值为when_playing时表示在播放时显示,值为always时表示一直显示。
  • show_timeout:控制栏的消失间隔,单位为毫秒。
  • use_controller:是否显示控制栏,值为true时表示显示控制栏,值为false时表示不显示控制栏。
  • resize_mode:缩放模式。值为fit表示保持宽高比例缩放,值为fill表示填满播放器界面。

下面是布局文件中添加PlayerView节点的配置:

<com.google.android.exoplayer2.ui.StyledPlayerView
    android:id="@+id/pv_content"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:show_buffering="always"
    app:show_timeout="5000"
    app:use_controller="true"
    app:resize_mode="fit"/>

回到活动页面的代码,再调用播放器视图的setPlayer方法,设置已经创建好的播放器对象,然后才能让播放器进行播空操作。设置播放器的代码模板如下:

// 创建一个新型播放器对象
private ExoPlayer mPlayer = new ExoPlayer.Builder(this).build();
StyledPlayerView pv_content = findViewById(R.id.pv_content);
pv_content.setPlayer(mPlayer); // 设置播放器视图的播放器对象

以上代码把StyledPlayerView与ExoPlayer关联起来,后续的视频播放过程分成以下几个步骤:

  1. 创建指定视频格式的工厂对象。
  2. 创建指定URI地址的媒体对象MediaItem。
  3. 基于格式工厂和媒体对象创建媒体来源MediaSource。
  4. 设置播放器对象的媒体来源以及其他的播控操作。

其中步骤4的操作与ExoPlayer有关,它的常见方法分别说明如下:

  • setMediaSource:设置播放器的媒体来源。

  • addListener:给播放添加时间事件监听器。需要重写监听器接口Player.Listener的onPlaybackStateChanged方法,根据状态参数判断事件类型(取值见下表)。
    |Player类的播放状态| 说明 |
    |–|–|
    | STATE_BUFFERING | 视频正在缓冲 |
    | STATE_READY | 视频准备就绪 |
    | STATE_ENDED | 视频播放完毕 |

  • prepare:播放器准备就绪。

  • play:播放器开始播放。

  • seekTo:拖动当前进度到指定位置。

  • isPlaying:判断播放器是否正在播放。

  • getCurrentPosition:获得播放器当前的播放位置。

  • pause:播放器暂停播放。

  • stop:播放器停止播放。

  • release:释放播放器资源。

接下来把网络视频与本地视频的播放代码整合到一起,从工厂构建到开始播放的示例代码如下:

private ExoPlayer mPlayer; // 声明一个新型播放器对象
// 播放视频
private void playVideo(Uri uri) {
    DataSource.Factory factory = new DefaultDataSource.Factory(this);
    // 创建指定地址的媒体对象
    MediaItem videoItem = new MediaItem.Builder().setUri(uri).build();
    // 基于工厂对象和媒体对象创建媒体来源
    MediaSource videoSource = new ProgressiveMediaSource.Factory(factory)
            .createMediaSource(videoItem);
    mPlayer.setMediaSource(videoSource); // 设置播放器的媒体来源
    // 给播放器添加事件监听器
    mPlayer.addListener(new Player.Listener() {
        @Override
        public void onPlaybackStateChanged(int state) {
            if (state == Player.STATE_BUFFERING) { // 视频正在缓冲
                Log.d(TAG, "视频正在缓冲");
            } else if (state == Player.STATE_READY) { // 视频准备就绪
                Log.d(TAG, "视频准备就绪");
            } else if (state == Player.STATE_ENDED) { // 视频播放完毕
                Log.d(TAG, "视频播放完毕");
            }
        }
    });
    mPlayer.prepare(); // 播放器准备就绪
    mPlayer.play(); // 播放器开始播放
}

再举个播放带字幕的视频例子,此时除了构建视频文件的媒体来源,还需要构建字幕文件的媒体来源(字幕文件为srt格式),然后合并视频的媒体来源与字幕来源得到最终的媒体来源。包含字幕处理的播放器代码如下:

// 播放带字幕的视频
private void playVideoWithSubtitle(Uri videoUri, Uri subtitleUri) {
    Log.d(TAG, "getLanguage="+Locale.getDefault().getLanguage());
    // 创建HTTP在线视频的工厂对象
    DataSource.Factory factory = new DefaultDataSource.Factory(this);
    // 创建指定地址的媒体对象
    MediaItem videoItem = new MediaItem.Builder().setUri(videoUri).build();
    // 基于工厂对象和媒体对象创建媒体来源
    MediaSource videoSource = new ProgressiveMediaSource.Factory(factory)
            .createMediaSource(videoItem);
    // 语言要填null,否则中文会乱码。selectionFlags要填Format.NO_VALUE,否则看不到字幕
    // 创建指定地址的字幕对象。ExoPlayer只支持srt字幕,不支持ass字幕
    MediaItem.Subtitle subtitleItem = new MediaItem.Subtitle(subtitleUri,
            MimeTypes.APPLICATION_SUBRIP, null, Format.NO_VALUE);
    // 基于工厂对象和字幕对象创建字幕来源
    MediaSource subtitleSource = new SingleSampleMediaSource.Factory(factory)
            .createMediaSource(subtitleItem, C.TIME_UNSET);
    // 合并媒体来源与字幕来源
    MergingMediaSource mergingSource = new MergingMediaSource(videoSource, subtitleSource);
    mPlayer.setMediaSource(mergingSource); // 设置播放器的媒体来源
    mPlayer.prepare(); // 播放器准备就绪
    mPlayer.play(); // 播放器开始播放
}

运行测试该App,可观察到ExoPlayer的播放效果如下图所示。其中,左图为网络视频的播放界面,右图为带字幕视频的播放界面。
在这里插入图片描述

工程源码

文章涉及所有代码可点击工程源码下载。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值