Android 媒体能力实战:从 Media3 音视频播放到 CameraX 拍照与视频录制

前言

这篇内容围绕 Android 中最常见的几类媒体能力展开:先用 Jetpack Media3 完成音频播放、状态监听、进度同步和视频播放,再补充 SoundPool 这类短音效场景的适用方式,最后接入 CameraX 完成相机预览、拍照、镜头切换与视频录制。全文按工程实现的推进顺序展开,重点不是只给出最终代码,而是把每一步为什么要写、关键对象如何配合、状态变化发生在什么节点,以及结果该如何验证交代清楚。

播放器部分的核心在于三件事:把媒体源正确装载到 ExoPlayer、让界面状态随着播放器状态和播放进度同步变化、并在页面进入前后台时正确处理资源。相机部分的核心也同样明确:先处理权限与预览,再把 ProcessCameraProviderPreviewViewImageCaptureVideoCapture 这些对象组织成稳定的生命周期闭环,在此基础上继续扩展拍照、镜头翻转和视频录制。

目录

  • [Android 媒体能力实战:从 Media3 音视频播放到 CameraX 拍照与视频录制](#Android 媒体能力实战:从 Media3 音视频播放到 CameraX 拍照与视频录制)
  • [1. 使用 Media3 构建音频播放器界面](#1. 使用 Media3 构建音频播放器界面)
    • [1.1 准备音频播放页面的布局与控件](#1.1 准备音频播放页面的布局与控件)
    • [1.2 引入依赖并准备网络音频资源](#1.2 引入依赖并准备网络音频资源)
  • [2. 用播放器回调感知装载状态与播放异常](#2. 用播放器回调感知装载状态与播放异常)
  • [3. 让进度条支持拖拽与总时长展示](#3. 让进度条支持拖拽与总时长展示)
  • [4. 用 Handler 持续同步播放进度](#4. 用 Handler 持续同步播放进度)
  • [5. 将 PlayerView 与 Activity 生命周期绑定](#5. 将 PlayerView 与 Activity 生命周期绑定)
  • [6. 使用 Media3 播放视频资源](#6. 使用 Media3 播放视频资源)
    • [6.1 准备视频播放页面与播放器初始化](#6.1 准备视频播放页面与播放器初始化)
    • [6.2 装载视频列表并使用自定义按钮控制播放](#6.2 装载视频列表并使用自定义按钮控制播放)
  • [7. 用 SoundPool 处理短音效播放场景](#7. 用 SoundPool 处理短音效播放场景)
  • [8. 集成 CameraX 并搭建相机预览页面](#8. 集成 CameraX 并搭建相机预览页面)
    • [8.1 添加 CameraX 依赖与权限声明](#8.1 添加 CameraX 依赖与权限声明)
    • [8.2 使用 PreviewView 承载预览画面](#8.2 使用 PreviewView 承载预览画面)
    • [8.3 动态申请相机权限](#8.3 动态申请相机权限)
  • [9. 用 CameraX 完成拍照与镜头切换](#9. 用 CameraX 完成拍照与镜头切换)
    • [9.1 绑定相机生命周期并开启预览](#9.1 绑定相机生命周期并开启预览)
    • [9.2 补齐拍照保存流程](#9.2 补齐拍照保存流程)
    • [9.3 在高版本 Android 中通过 MediaStore 保存照片](#9.3 在高版本 Android 中通过 MediaStore 保存照片)
    • [9.4 抽离绑定逻辑以支持镜头翻转](#9.4 抽离绑定逻辑以支持镜头翻转)
    • [9.5 切换前后置摄像头](#9.5 切换前后置摄像头)
    • [9.6 释放拍照线程资源](#9.6 释放拍照线程资源)
  • [10. 使用 CameraX 录制视频](#10. 使用 CameraX 录制视频)
    • [10.1 搭建视频录制预览页面](#10.1 搭建视频录制预览页面)
    • [10.2 绑定视频录制能力并启动预览](#10.2 绑定视频录制能力并启动预览)
    • [10.3 开始录制视频](#10.3 开始录制视频)
    • [10.4 结束录制并回收资源](#10.4 结束录制并回收资源)
  • [11. 相关代码附录](#11. 相关代码附录)
    • [11.1 音频播放相关代码](#11.1 音频播放相关代码)
    • [11.2 视频播放相关代码](#11.2 视频播放相关代码)
    • [11.3 CameraX 拍照相关代码](#11.3 CameraX 拍照相关代码)
    • [11.4 CameraX 视频录制相关代码](#11.4 CameraX 视频录制相关代码)

1. 使用 Media3 构建音频播放器界面

音频播放的第一步不是直接调用 play(),而是先把界面、控制按钮、时间显示、进度条和播放器承载控件准备完整。只有这些对象先就位,后面的媒体装载、状态监听和进度同步才有可以落地的界面位置。

1.1 准备音频播放页面的布局与控件

页面最上方依次放置"装载媒体资源""播放""暂停""停止"四个按钮,中间用 TextView + SeekBar + TextView 展示当前进度和总时长,底部再放一个 PlayerView。这样的布局划分有两个直接目的:上半部分负责控制,下半部分负责承载播放器的可视化界面,后续无论是纯音频控制还是补入 PlayerView 的内置控制条,都不需要重新调整页面结构。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="16dp"
    tools:context=".AudioActivity">

    <Button
        android:id="@+id/btn_prepare"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="装载媒体资源"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/btn_play"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="播放"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/btn_prepare" />

    <Button
        android:id="@+id/btn_pause"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="暂停"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/btn_play" />

    <Button
        android:id="@+id/btn_stop"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="停止"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/btn_pause" />

    <TextView
        android:id="@+id/tv_druation"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="18dp"
        android:text="00:00:00"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/btn_stop" />

    <SeekBar
        android:id="@+id/seek_bar"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="@id/tv_druation"
        app:layout_constraintEnd_toStartOf="@id/tv_total"
        app:layout_constraintStart_toEndOf="@id/tv_druation"
        app:layout_constraintTop_toTopOf="@id/tv_druation" />

    <TextView
        android:id="@+id/tv_total"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="00:00:00"
        app:layout_constraintBottom_toBottomOf="@id/tv_druation"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/seek_bar"
        app:layout_constraintTop_toTopOf="@id/tv_druation" />


    <androidx.media3.ui.PlayerView
        android:id="@+id/player_view"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_total" />
</androidx.constraintlayout.widget.ConstraintLayout>

项目路径:MediaByJavaProject/app/src/main/res/layout/activity_audio.xml

这份布局对应的初始页面如下。按钮、时间文本、进度条和 PlayerView 都已经具备,后面的播放器代码只需要把行为填进去即可。

Activity 中,先把这些控件绑定出来,再为拖拽、装载、播放、暂停和停止各自挂上点击逻辑。这里的关键不是某一个按钮本身,而是把音频控制拆成四个明确动作:先 prepare 装载资源,再 play 开始播放,播放中可以 pause 暂停,彻底结束则调用 stop 停止。

java 复制代码
public class AudioActivity extends AppCompatActivity {

private static final String TAG = "AudioActivity";
private ExoPlayer player;
private TextView tvDruation;
private TextView tvTotal;
private SeekBar seekBar;
private Handler handler;
private Runnable runnable;
private PlayerView playerView;

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_audio);

  playerView = findViewById(R.id.player_view);

  tvDruation = findViewById(R.id.tv_druation);
  tvTotal = findViewById(R.id.tv_total);
  seekBar = findViewById(R.id.seek_bar);

  seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
      @Override
      public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
          if (fromUser) {
              //拖拽进度条的时候更新播放进度
              player.seekTo(progress);
          }
      }

      @Override
      public void onStartTrackingTouch(SeekBar seekBar) {

      }

      @Override
      public void onStopTrackingTouch(SeekBar seekBar) {

      }
  });
  findViewById(R.id.btn_prepare).setOnClickListener(view -> {

      //构建MediaItem,以便Player使用
      MediaItem mediaItem = MediaItem.fromUri(getNetWorkUri());
      player.setMediaItem(mediaItem);

      //装载、加载媒体
      player.prepare();
  });
  //播放
  findViewById(R.id.btn_play).setOnClickListener(view -> {
      //如果已经在播放状态  那就不要play
      if (!player.isPlaying()) {
          //调用播放方法
          player.play();
      }
  });
  //暂停
  findViewById(R.id.btn_pause).setOnClickListener(view -> {
      if (player.isPlaying()) {
          //暂停播放
          player.pause();
      }
  });
  //停止
  findViewById(R.id.btn_stop).setOnClickListener(view -> {
      //停止
      player.stop();
  });
  initPlayer();
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

这段代码里几个对象的职责是明确分开的:

  • playerView 负责承载播放器可视化界面。
  • tvDruation 用来显示当前播放时间。
  • tvTotal 用来显示媒体总时长。
  • seekBar 既承担进度展示,也承担拖拽跳播。
  • player 是真正执行媒体装载和播放控制的核心对象。

四个按钮对应的调用顺序也必须分清。只有先调用 player.setMediaItem(mediaItem)player.prepare(),播放器才真正装载了媒体;播放按钮里的 player.play() 只是针对已经装载好的媒体发出开始指令。停止按钮调用 player.stop() 后,当前媒体会被停止并退出可继续播放的状态,因此再次点击播放并不能直接恢复,而是需要重新装载资源。

1.2 引入依赖并准备网络音频资源

播放器能工作,前提是工程里先有 Media3 的核心依赖。这里先接入 ExoPlayer,后面在需要播放器可视化控件时再补入 media3-ui

javascript 复制代码
implementation "androidx.media3:media3-exoplayer:1.0.0"

项目路径:MediaByJavaProject/app/build.gradle

媒体源这里直接使用网络音频地址。之所以单独封装一个 getNetworkUri(),是为了把"媒体地址来源"和"播放器装载逻辑"拆开:当前示例里地址是硬编码字符串,实际项目中则完全可以替换成服务端返回的 json 字段。

java 复制代码
private Uri getNetworkUri() {
    // 这里直接给一个服务器上的音频文件地址,如果是实际开发中,这个地址会通过json的形式返回
    String musicPath = "http://titok.fzqq.fun/uploads/20241007/09557aab316732bcc04e8fc3a24df8a2.mp3";

    // 把url路径转为uri
    Uri uri = Uri.parse(musicPath);
    return uri;
}

播放的是 http 资源,所以权限声明不能缺。INTERNET 决定应用能否发起网络访问,android:usesCleartextTraffic="true" 则决定 Android 是否允许当前应用直接访问明文 HTTP 地址;如果少了后者,很多设备上即使有网络权限也会直接拦截请求。

xml 复制代码
<uses-permission android:name="android.permission.INTERNET" />

<application
    android:allowBackup="true"
    android:dataExtractionRules="@xml/data_extraction_rules"
    android:fullBackupContent="@xml/backup_rules"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/Theme.MediaByJavaProject"
    android:usesCleartextTraffic="true"
    tools:targetApi="31">

项目路径:MediaByJavaProject/app/src/main/AndroidManifest.xml

把依赖和权限准备好以后,媒体装载与控制的核心调用顺序就固定下来了:

  • 通过 MediaItem.fromUri(getNetworkUri()) 构建单个媒体项。
  • 调用 player.setMediaItem(mediaItem) 把媒体项交给播放器。
  • 调用 player.prepare() 真正开始装载媒体。
  • player.isPlaying() 判断当前是不是正在播放,避免重复执行 play()
  • 在暂停和停止按钮中分别调用 pause()stop(),让不同状态的语义保持清晰。
java 复制代码
findViewById(R.id.btn_prepare).setOnClickListener(view -> {

    //构建MediaItem,以便Player使用
    MediaItem mediaItem = MediaItem.fromUri(getNetWorkUri());
    player.setMediaItem(mediaItem);

    //装载、加载媒体
    player.prepare();
});
//播放
findViewById(R.id.btn_play).setOnClickListener(view -> {
    //如果已经在播放状态  那就不要play
    if (!player.isPlaying()) {
        //调用播放方法
        player.play();
    }
});
//暂停
findViewById(R.id.btn_pause).setOnClickListener(view -> {
    if (player.isPlaying()) {
        //暂停播放
        player.pause();
    }
});
//停止
findViewById(R.id.btn_stop).setOnClickListener(view -> {
    //停止
    player.stop();
});

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

实际操作时要按下面的顺序验证:

  • 先点击"装载媒体资源",让播放器开始准备当前 uri 对应的音频。
  • 再点击"播放",检查网络音频是否开始播放。
  • 点击"暂停"时,确认当前这条音频停在当前位置。
  • 点击"停止"后,确认当前媒体已经被取消装载,不能直接继续播放,必须重新执行一次装载流程。

2. 用播放器回调感知装载状态与播放异常

播放器能发声只是第一步。如果只会调用 prepare()play(),页面并不知道媒体有没有装载成功,也不知道当前处于缓存、就绪、结束还是报错状态。要解决这个问题,就必须把播放器的状态回调接进来。

player.addListener(new Player.Listener() { ... }) 里最常用的是三类回调:

  • onPlaybackStateChanged(int playbackState):感知播放器的状态切换。
  • onIsPlayingChanged(boolean isPlaying):感知当前是否正在播放。
  • onPlayerError(PlaybackException error):感知播放过程中的错误。

这几个回调分别承担不同职责。状态回调用来驱动 UI 和日志;播放状态回调用来判断当前是否真的开始播了;错误回调用来识别失败原因,并在必要时给用户提示。

初始化播放器时,把这些监听逻辑一次性挂进去:

java 复制代码
private void initPlayer() {
    //创建一个exoPlayer
    player = new ExoPlayer.Builder(this).build();
    player.addListener(new Player.Listener() {
        /**
         * 空闲、初始状态:IDLE
         * 播放器正在缓存中,需要加载数据:BUFFERING
         * 准备完毕:READY
         * 播放完毕:ENDED
         * @param playbackState The new playback {@link State}.
         */
        @Override
        public void onPlaybackStateChanged(int playbackState) {
            switch (playbackState) {
                case Player.STATE_IDLE:
                    Log.i(TAG, "onPlaybackStateChanged: 播放器空闲");
                    break;
                case Player.STATE_BUFFERING:
                    Log.i(TAG, "onPlaybackStateChanged: 缓冲...");
                    break;
                case Player.STATE_READY:
                    //player.play();
                    //获取当前装载好的媒体资源播放时长
                    long duration = player.getDuration();
                    seekBar.setMax((int) duration);
                    String formatTime = formatTime(duration);
                    tvTotal.setText(formatTime);
                    Log.i(TAG, "onPlaybackStateChanged: 播放器准备就绪,当前资源总时长:" + duration);
                    break;
                case Player.STATE_ENDED:
                    Log.i(TAG, "onPlaybackStateChanged: 播放器播放完毕");
                    break;
            }
//                Player.Listener.super.onPlaybackStateChanged(playbackState);
            Log.i(TAG, "onPlaybackStateChanged: playbackState = " + playbackState);
        }

        @Override
        public void onIsPlayingChanged(boolean isPlaying) {
            Log.i(TAG, "onIsPlayingChanged: isPlaying = " + isPlaying);
        }

        @Override
        public void onPlayerError(PlaybackException error) {
//                Player.Listener.super.onPlayerError(error);
            Log.i(TAG, "onPlayerError: error = " + error);

            if (error.errorCode == PlaybackException.ERROR_CODE_TIMEOUT) {
                Toast.makeText(AudioActivity.this, "加载超时", Toast.LENGTH_SHORT).show();
            }
        }
    });

    playerView.setPlayer(player);

}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

这段监听代码里有几处细节:

  • Player.STATE_IDLE 表示播放器空闲或刚初始化,还没有进入有效播放链路。
  • Player.STATE_BUFFERING 表示资源还在加载或缓存中,这个阶段即使点了播放,也可能还不会真正出声。
  • Player.STATE_READY 才表示媒体已经准备就绪,此时可以安全读取总时长,并把进度条最大值和总时长文本同步出来。
  • Player.STATE_ENDED 表示媒体已经播完,后面如果要做重播或切换下一条资源,就要基于这个状态继续扩展。

错误回调里的 PlaybackException 也不是一句"播放失败"就能带过。这里特别判断了 error.errorCode == PlaybackException.ERROR_CODE_TIMEOUT,说明当前示例重点关注的是网络资源加载超时。当命中这个错误码时,页面会直接弹出"加载超时"的 Toast,让错误处理从日志层真正落到用户可感知的界面反馈。

3. 让进度条支持拖拽与总时长展示

播放器有声音以后,下一步是把时间维度同步到界面。进度条这部分不是单纯展示一个控件,而是同时解决两个问题:一是让用户知道总时长是多少,二是允许用户主动拖动到指定播放位置。

先把 SeekBar 放进页面中,让它位于当前时间和总时长之间。这样左右两个时间文本的语义就固定了:左侧显示当前进度,右侧显示总时长,中间的条形控件负责承接拖拽操作。

xml 复制代码
<SeekBar
    android:id="@+id/seek_bar"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="@id/tv_druation"
    app:layout_constraintEnd_toStartOf="@id/tv_total"
    app:layout_constraintStart_toEndOf="@id/tv_druation"
    app:layout_constraintTop_toTopOf="@id/tv_druation" />

项目路径:MediaByJavaProject/app/src/main/res/layout/activity_audio.xml

然后为 SeekBar 挂上拖拽监听。这里真正起作用的是 onProgressChanged(...) 里的 fromUser 判断,因为只有用户手动拖动时才应该调用 player.seekTo(progress);如果不加这个判断,后面代码更新进度条时同样会触发回调,逻辑就会互相干扰。

java 复制代码
seekBar = findViewById(R.id.seek_bar);

seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
    @Override
    public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
        if (fromUser) {
            //拖拽进度条的时候更新播放进度
            player.seekTo(progress);
        }
    }

    @Override
    public void onStartTrackingTouch(SeekBar seekBar) {

    }

    @Override
    public void onStopTrackingTouch(SeekBar seekBar) {

    }
});

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

仅有拖拽监听还不够,因为进度条必须知道自己的总范围。这个范围不是手写死的,而是在 onPlaybackStateChanged() 监听到 STATE_READY 时,通过 player.getDuration() 动态拿到。此时媒体已经装载完成,总时长才可靠,所以把 seekBar.setMax((int) duration) 和总时长文本更新挂在这里最合适。

java 复制代码
case Player.STATE_READY:
    //player.play();
    //获取当前装载好的媒体资源播放时长
    long duration = player.getDuration();
    seekBar.setMax((int) duration);
    Log.i(TAG, "onPlaybackStateChanged: 播放器准备就绪,当前资源总时长:" + duration);
    break;

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

当总时长设置完成以后,就可以先播放音乐,再拖拽进度条,检查播放位置是否跟着改变:

拖拽过程中继续观察日志,可以确认播放器已经收到了新的跳播位置:

4. 用 Handler 持续同步播放进度

到这里,进度条已经可以拖动,但页面还缺一个关键能力:播放器在正常播放时,SeekBar 和当前时间文本不会自动前进。要解决这个问题,就需要持续读取当前播放位置,并把结果定时推送给 UI。

先把毫秒值转换成 时:分:秒 格式。这里的实现不是简单拼接,而是把小时、分钟和秒分别从毫秒值里拆出来,再通过 String.format("%02d:%02d:%02d", ...) 补齐两位数显示。

注意:%02d:%02d:%02d 表示至少需要填 2 位数,不足两位数用 0 做填充;

java 复制代码
/**
 * 获取到的媒体时间转成时分秒的形式
 *
 * @param time
 * @return
 */
private String formatTime(long time) {
    //242703  4分钟出头  00
    long hours = (time / (1000 * 60 * 60));
    long minutes = (time / (1000 * 60)) % 60;
    long seconds = (time / (1000)) % 60;
    return String.format("%02d:%02d:%02d", hours, minutes, seconds);
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

这个转换方法先用在总时长文本上。播放器进入 STATE_READY 时,拿到毫秒级总时长后,立刻调用 formatTime(duration),再把结果放到右侧的 tvTotal 中。这样右侧文本显示的就是和 SeekBar 最大值一一对应的可读时间格式。

java 复制代码
case Player.STATE_READY:
    //player.play();
    //获取当前装载好的媒体资源播放时长
    long duration = player.getDuration();
    seekBar.setMax((int) duration);
		// 拼接好媒体时长的时分秒格式,拼接到进度条右侧文本控件上
    String formatTime = formatTime(duration);
    tvTotal.setText(formatTime);
    Log.i(TAG, "onPlaybackStateChanged: 播放器准备就绪,当前资源总时长:" + duration);
    break;

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

接下来处理实时进度更新。这里使用的是 Handler + Runnable 的循环推送方式,几个实现细节要一起理解:

  • handlerrunnable 被定义成成员变量,不是局部变量。
  • runnable.run() 每次执行时都会读取播放进度 player.getCurrentPosition()
  • 读取到的当前进度既要同步到 seekBar.setProgress((int), currentPosition),也要同步到左侧时间文本 tvDruation`。
  • handler.postDelayed(this, 1000) 让同一个任务每秒再执行一次,形成持续更新。
java 复制代码
handler = new Handler();
runnable = new Runnable() {
    @Override
    public void run() {
        //获取到当前播放进度
        long currentPosition = player.getCurrentPosition();
        seekBar.setProgress((int) currentPosition);
        tvDruation.setText(formatTime(currentPosition));
      
        handler.postDelayed(this, 1000);//每隔一秒种更新一次进度
    }
};

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

成员变量这一点很关键。因为这个循环任务后面还需要在 onDestroy() 中显式终止,如果把 Runnable 写成局部对象,就没法在页面销毁时准确拿到同一个任务实例。

因此,页面关闭时必须调用:

java 复制代码
@Override
protected void onDestroy() {
    super.onDestroy();
    player.release();
    //页面销毁的时候停止进度更新
    handler.removeCallbacks(runnable);
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

正常播放时,可以看到进度条和左侧时间文本每秒推进一次;拖动进度条以后,播放器的当前进度被改写,下一轮 Handler 更新就会把新的位置反映到文本和滑块上。

5. 将 PlayerView 与 Activity 生命周期绑定

前面的按钮控制和时间同步已经能覆盖基本音频播放,但如果希望播放器具备更完整的可视化控制能力,还需要把 media3-ui 引进来,并让 PlayerView 真正和 ExoPlayer 关联。

先补上 UI 依赖:

javascript 复制代码
implementation "androidx.media3:media3-ui:1.0.0"

项目路径:MediaByJavaProject/app/build.gradle

然后在布局中放入 PlayerView。它位于页面底部,占用剩余空间,后面一旦绑定了 player,就能直接使用内置的播放控制条。

xml 复制代码
<androidx.media3.ui.PlayerView
    android:id="@+id/player_view"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/tv_total" />

项目路径:MediaByJavaProject/app/src/main/res/layout/activity_audio.xml

真正的绑定代码只有一句,在 initPlayer 方法的状态监听回调后,将 playerView 和 player关联,表示的是播放器可视化能力和播放器进行绑定:

java 复制代码
playerView.setPlayer(player);

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

绑定成功以后,页面底部会直接变成一个完整的播放器区域:

只要先装载音频资源,就可以继续使用 PlayerView 内部提供的上一首、下一首、暂停和快进快退等控制按钮,播放器本身也会继续触发相关状态回调:

最后还要把播放器和 Activity 生命周期对齐。这里不能只在页面销毁时释放资源,因为页面退到后台但尚未销毁时,如果不主动暂停,音频仍会继续播放。

后台不可见时暂停:

java 复制代码
@Override
protected void onStop() {
    super.onStop();
    //当Activity不可见 暂停
    player.setPlayWhenReady(false);
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

回到前台时恢复:

java 复制代码
@Override
protected void onStart() {
    super.onStart();
    player.setPlayWhenReady(true);
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

页面最终销毁时释放播放器并终止进度更新任务:

java 复制代码
@Override
protected void onDestroy() {
    super.onDestroy();
    player.release();
    //页面销毁的时候停止进度更新
    handler.removeCallbacks(runnable);
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

这一段的验证顺序也要按动作来做:

  • 先装载媒体并开始播放。
  • 按下 Home 键或切到桌面,确认音频是否停止。
  • 再回到当前页面,确认是否继续播放。
  • 退出页面后,确认播放器和进度更新任务都不再继续占用资源。

6. 使用 Media3 播放视频资源

视频播放和音频播放的底层播放器是一套机制,差别主要在于视频必须有稳定的视频画面承载区域,同时很多时候还需要控制是否使用 PlayerView 的内置 UI。

6.1 准备视频播放页面与播放器初始化

视频页面的主体是一个占满剩余空间的 PlayerView。在当前实现里,PlayerView 关闭了默认控制条,因为后面会在布局底部追加自己的进度条和播放按钮。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".VideoActivity">


    <androidx.media3.ui.PlayerView
        android:id="@+id/player_view"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:background="@color/black"
        app:use_controller="false" />

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <SeekBar
            android:id="@+id/seek_bar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

        <ImageView
            android:id="@+id/iv_contrl"
            android:layout_width="44dp"
            android:layout_height="44dp"
            android:layout_below="@id/seek_bar"
            android:layout_centerHorizontal="true"
            android:src="@mipmap/icon_zanting" />

    </RelativeLayout>

</LinearLayout>

项目路径:MediaByJavaProject/app/src/main/res/layout/activity_video.xml

初始化流程和音频播放器基本一致,仍然是先创建 ExoPlayer,再挂状态监听,最后把播放器实例交给 playerView。区别在于视频这里在 STATE_READY 状态下直接调用了 player.play(),也就是资源一旦准备好就自动开始播放。

java 复制代码
public class VideoActivity extends AppCompatActivity {

    private static final String TAG = "VideoActivity";
    private PlayerView playerView;
    private ExoPlayer player;
    private ImageView imageContrl;
    private SeekBar seekBar;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_video);

        imageContrl = findViewById(R.id.iv_contrl);
        seekBar = findViewById(R.id.seek_bar);

        playerView = findViewById(R.id.player_view);
        initPlayer();

    }


    private void initPlayer() {
        //创建一个exoPlayer
        player = new ExoPlayer.Builder(this).build();
        player.addListener(new Player.Listener() {
            /**
             * 空闲、初始状态:IDLE
             * 播放器正在缓存中,需要加载数据:BUFFERING
             * 准备完毕:READY
             * 播放完毕:ENDED
             * @param playbackState The new playback {@link State}.
             */
            @Override
            public void onPlaybackStateChanged(int playbackState) {
                switch (playbackState) {
                    case Player.STATE_IDLE:
                        Log.i(TAG, "onPlaybackStateChanged: 播放器空闲");
                        break;
                    case Player.STATE_BUFFERING:
                        Log.i(TAG, "onPlaybackStateChanged: 缓冲...");
                        break;
                    case Player.STATE_READY:
                        player.play();
                        //获取当前装载好的媒体资源播放时长
                        long duration = player.getDuration();

                        Log.i(TAG, "onPlaybackStateChanged: 播放器准备就绪,当前资源总时长:" + duration);
                        break;
                    case Player.STATE_ENDED:
                        Log.i(TAG, "onPlaybackStateChanged: 播放器播放完毕");
                        break;
                }
//                Player.Listener.super.onPlaybackStateChanged(playbackState);
                Log.i(TAG, "onPlaybackStateChanged: playbackState = " + playbackState);
            }

            @Override
            public void onIsPlayingChanged(boolean isPlaying) {
                Log.i(TAG, "onIsPlayingChanged: isPlaying = " + isPlaying);
            }

            @Override
            public void onPlayerError(PlaybackException error) {
//                Player.Listener.super.onPlayerError(error);
                Log.i(TAG, "onPlayerError: error = " + error);

                if (error.errorCode == PlaybackException.ERROR_CODE_TIMEOUT) {
                    Toast.makeText(VideoActivity.this, "加载超时", Toast.LENGTH_SHORT).show();
                }
            }
        });

        playerView.setPlayer(player);
    }

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/VideoActivity.java

这里的三个监听仍然各司其职:

  • onPlaybackStateChanged() 负责识别空闲、缓存、就绪、播放完成等阶段。
  • onIsPlayingChanged() 用来判断当前视频是否真的开始播了。
  • onPlayerError() 用来处理超时等失败分支。

自动播放放在 STATE_READY 中也有明确原因:只有视频资源已经准备完成,调用 play() 才不会落空,同时还能先拿到当前媒体的总时长做日志确认。

6.2 装载视频列表并使用自定义按钮控制播放

视频资源这里不是单条 MediaItem,而是一个列表。初始化播放器之后,先创建 ArrayList<MediaItem>,再把三条视频地址依次包装成 MediaItem 放进去,最后统一交给播放器。

java 复制代码
initPlayer();

//添加播放内容
ArrayList<MediaItem> mediaItems = new ArrayList<>();

MediaItem mediaItem1 = MediaItem.fromUri(Uri.parse("http://titok.fzqq.fun//uploads/20241010/b054fb0478e8c3d95a518cf5e1b67163.mp4"));
MediaItem mediaItem2 = MediaItem.fromUri(Uri.parse("http://titok.fzqq.fun//uploads/20241010/5043fbca9fabb94fba2668c4a827d3a5.mp4"));
MediaItem mediaItem3 = MediaItem.fromUri(Uri.parse("http://titok.fzqq.fun//uploads/20241010/02577b1fabe3cbbdd75c3f6a02434efd.mp4"));

mediaItems.add(mediaItem1);
mediaItems.add(mediaItem2);
mediaItems.add(mediaItem3);

player.setMediaItems(mediaItems);
player.prepare();

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/VideoActivity.java

player.setMediaItems(mediaItems) 的作用不是立刻播放,而是先把播放列表整体装进播放器;真正开始解析和缓存列表内容的是 player.prepare()。配合前面 STATE_READY 里的 player.play(),装载完成后视频就会自动开始播放。

如果不想使用 PlayerView 默认控制条,就可以像当前页面这样把 app:use_controller="false" 关掉,然后自己在布局里追加按钮和进度条:

xml 复制代码
<androidx.media3.ui.PlayerView
    android:id="@+id/player_view"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1"
    android:background="@color/black"
    app:use_controller="false" />

<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <SeekBar
        android:id="@+id/seek_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <ImageView
        android:id="@+id/iv_contrl"
        android:layout_width="44dp"
        android:layout_height="44dp"
        android:layout_below="@id/seek_bar"
        android:layout_centerHorizontal="true"
        android:src="@mipmap/icon_zanting" />

</RelativeLayout>

项目路径:MediaByJavaProject/app/src/main/res/layout/activity_video.xml

自定义控制按钮的逻辑也很直接,但每个分支都不能省:

  • 如果当前 player.isPlaying()true,说明视频正在播放,此时点击按钮应该执行 pause() 并把图标切换成播放图标。
  • 如果当前不在播放,则执行 play() 并把图标切回暂停图标。
java 复制代码
player.setMediaItems(mediaItems);
player.prepare();


imageContrl.setOnClickListener(view -> {
    if (player.isPlaying()) {
        player.pause();
        imageContrl.setImageResource(R.mipmap.icon_bofang);
    } else {
        player.play();
        imageContrl.setImageResource(R.mipmap.icon_zanting);
    }
});

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/VideoActivity.java

为了让视频在页面进出前后台时也能正确切换状态,VideoActivity 同样实现了和音频播放器一致的生命周期处理:

java 复制代码
@Override
protected void onStart() {
    super.onStart();
    player.setPlayWhenReady(true);
}

@Override
protected void onStop() {
    super.onStop();
    player.setPlayWhenReady(false);
}

@Override
protected void onDestroy() {
    super.onDestroy();
    player.release();
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/VideoActivity.java

装载好后自动播放视频,可以在媒体资源状态变化监听回调方法中设置,当媒体资源状态为准备就绪,就播放媒体资源:

java 复制代码
case Player.STATE_READY:
    player.play();
		// ....
    break;

7. 用 SoundPool 处理短音效播放场景

除了完整的音频和视频播放,Android 里还有一类典型场景是短音效,比如按钮点击声、提示音、游戏里的爆炸声。这个场景并不适合沿用完整播放器链路,更常见的做法是使用 SoundPool

SoundPool 适合小体积、低延迟、需要快速触发的音频资源。它的基本使用步骤是固定的:

  • 创建 SoundPool 实例。
  • 加载音频文件。
  • 在需要的时候播放。
  • 页面销毁或不再需要时释放资源。

如果短音资源直接跟随应用打包,一般会把文件放到 res/raw 目录下,再通过 R.raw.xxx 访问:

完整示例如下:

java 复制代码
import android.media.AudioAttributes;
import android.media.SoundPool;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {

    private SoundPool soundPool;
    private int soundId;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 创建SoundPool实例
        AudioAttributes audioAttributes = new AudioAttributes.Builder()
                .setUsage(AudioAttributes.USAGE_MEDIA)
                .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
                .build();

        soundPool = new SoundPool.Builder()
                .setMaxStreams(5) // 最多支持5个并发流
                .setAudioAttributes(audioAttributes)
                .build();

        // 加载音频文件 
        // soundId = soundPool.load(context, resId, priority);
        soundId = soundPool.load(this, R.raw.short_sound, 1); // R.raw.short_sound 是音频文件

        // 播放音频
        findViewById(R.id.play_button).setOnClickListener(v -> {
            //soundId:音频的 ID,load() 方法返回的值。
            //leftVolume 和 rightVolume:音量,范围是 0.0 到 1.0。
            //priority:播放的优先级,值越大,优先级越高。
            //loop:循环次数,0 表示不循环,-1 表示无限循环。
            //rate:播放速度,1 表示正常速度,0.5 是半速,2 是两倍速。
            soundPool.play(soundId, 1, 1, 0, 0, 1); 
        });
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 释放资源
        soundPool.release();
        soundPool = null;
    }
}

这里每个参数都有明确作用:

  • soundIdload() 返回的音频标识。
  • 左右声道音量决定播放强度。
  • priority 决定并发流之间的优先级。
  • loop 控制循环次数,0 表示不循环,-1 表示无限循环。
  • rate 控制播放速度。

也就是说,Media3 更适合完整媒体播放链路,SoundPool 更适合需要快速触发的短音效,两者解决的是不同层级的问题。

8. 集成 CameraX 并搭建相机预览页面

媒体能力不止于播放。进入相机部分以后,重点会从"播放器状态和进度"切换成"权限、预览和生命周期绑定"。对大多数普通拍照和录制需求来说,CameraX 是比旧版 Camera 和底层 Camera2 更容易落地的方案。

8.1 添加 CameraX 依赖与权限声明

先把 CameraX 相关依赖加到工程中。这里使用 camerax_version 把版本号抽出来,是为了保证多个模块统一升级时只改一个地方。

javascript 复制代码
def camerax_version = "1.2.0-alpha04"

//核心库
implementation "androidx.camera:camera-core:$camerax_version"
//基于 Camera2 的实现模块
implementation "androidx.camera:camera-camera2:$camerax_version"
//自动管理相机的生命周期
implementation "androidx.camera:camera-lifecycle:$camerax_version"
//显示相机预览的 UI 组件
implementation "androidx.camera:camera-view:$camerax_version"
// 视频录制相关库
implementation "androidx.camera:camera-video:$camerax_version" 

项目路径:MediaByJavaProject/app/build.gradle

权限声明里至少有两项:

  • android.permission.CAMERA 决定应用能否访问相机。
  • uses-feature 中的 android.hardware.camera 用来声明硬件特性。
xml 复制代码
<uses-feature
        android:name="android.hardware.camera"
        android:required="false" />

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />

项目路径:MediaByJavaProject/app/src/main/AndroidManifest.xml

这里的 android:required="false" 不能忽略。它表示"当前应用可以使用相机,但相机不是应用安装的硬性前提"。这样即使设备本身没有相机硬件,应用仍然可以安装运行;如果写成 true,那类设备连安装资格都没有。

8.2 使用 PreviewView 承载预览画面

预览页面的核心控件是 PreviewView。它负责把相机实时画面显示出来,因此通常直接铺满整个页面。底部再放一个拍照按钮,右上角放一个镜头切换按钮,为后面的拍照和前后摄像头翻转做准备。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".CameraPhotoActivity">

    <androidx.camera.view.PreviewView
        android:id="@+id/preview_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />


    <TextView
        android:id="@+id/tv_capture"
        android:layout_width="66dp"
        android:layout_height="66dp"
        android:background="@color/white"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.95" />

    <ImageView
        android:id="@+id/iv_switch"
        android:layout_width="44dp"
        android:layout_height="44dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="16dp"
        android:src="@mipmap/icon_switch_camera"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout>

项目路径:MediaByJavaProject/app/src/main/res/layout/activity_camera_photo.xml

Activity 初始化阶段先绑定 PreviewView,再把拍照按钮和镜头切换按钮的点击监听预留出来。这里先把入口搭起来,真正的拍照与切换动作会在后续小节逐步补齐。

java 复制代码
public class CameraPhotoActivity extends AppCompatActivity {
    private static final String TAG = "CameraPhotoActivity";
    private PreviewView previewView;
    private ImageCapture imageCapture;
    private ExecutorService threadExecutor;
    private ProcessCameraProvider processCameraProvider;
    private CameraSelector cameraSelector;

    //是否是前置摄像头
    private boolean isFrontCamera;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_camera_photo);

        previewView = findViewById(R.id.preview_view);

        findViewById(R.id.tv_capture).setOnClickListener(view -> {
            takePhoto();
        });
      
        findViewById(R.id.iv_switch).setOnClickListener(view -> {
            isFrontCamera = !isFrontCamera;
            switchCamera();
        });
    }
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

8.3 动态申请相机权限

清单里写了 CAMERA 权限,不代表运行时已经拿到。相机能力是典型的危险权限,进入页面时必须先检查当前授权状态。

权限检查逻辑拆成单独方法:

java 复制代码
private boolean isPermission() {
    int permission = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA);
    boolean b = permission == PackageManager.PERMISSION_GRANTED;
    return b;
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

这段代码的判断过程是明确的:

  • ContextCompat.checkSelfPermission(...) 返回当前权限状态常量。
  • 返回值与 PackageManager.PERMISSION_GRANTED 对比后,才能得出"当前页面是否已经有相机权限"。

接下来在 onCreate() 里按结果分支处理:

  • 在 onCreate 中,当调用 isPermission() 返回 false,调用 ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, 100) 弹出弹窗,让用户决定是否同意相机权限申请;

  • ActivityCompat.requestPermissions(申请权限的 activity, 要申请的权限数组, 申请的权限的返回码映射)

java 复制代码
@Override
  protected void onCreate(Bundle savedInstanceState) {
      // .....
      findViewById(R.id.iv_switch).setOnClickListener(view -> {
      });


      if (isPermission()) {
          //启动相机
          startCamera();
      } else {
          ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, 100);
      }

  }

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

权限回调里也不能笼统写成"处理结果"。这段逻辑的分支意义是明确的:

  • requestCode == 100 表示当前处理的就是前面发出的相机权限请求。
  • 再次调用 isPermission(),是为了以统一方式读取当前最终授权结果。
  • 授权成功就 startCamera()
  • 授权失败则给出错误提示,并直接 finish() 结束当前页面,避免进入一个没有相机能力却还停留在相机页面的异常状态。
java 复制代码
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    if (requestCode == 100) {
        if (isPermission()) {
            startCamera();
        } else {
            Toast.makeText(this, "权限获取异常!", Toast.LENGTH_SHORT).show();
            finish();
        }
    }
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

9. 用 CameraX 完成拍照与镜头切换

权限处理完以后,真正的重点变成如何把相机预览、拍照能力和页面生命周期绑定起来。这里最关键的对象是 ProcessCameraProvider,它相当于 CameraX 提供的相机管理入口。

9.1 绑定相机生命周期并开启预览

启动相机时,先调用 ProcessCameraProvider.getInstance(this) 拿到一个异步任务对象。这个方法不会立刻返回可用的相机实例,所以必须再对它添加监听,等到真正准备完成后,才能继续做预览和拍照绑定。

java 复制代码
private void startCamera() {
    ListenableFuture<ProcessCameraProvider> cameraProvider =
            ProcessCameraProvider.getInstance(this);

    cameraProvider.addListener(new Runnable() {
        @Override
        public void run() {
            try {
                ProcessCameraProvider processCameraProvider = cameraProvider.get();

                // 创建预览
                Preview preview = new Preview.Builder().build();
                preview.setSurfaceProvider(previewView.getSurfaceProvider());

                CameraSelector cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA;

                imageCapture = new ImageCapture.Builder().build();
								
              	//先解绑之前的
        				processCameraProvider.unbindAll();
        				
              	//通过cameraProvider进行相机的生命周期管理
                processCameraProvider.bindToLifecycle(
                        CameraPhotoActivity.this,
                        cameraSelector,
                        preview,
                        imageCapture
                );

            } catch (ExecutionException e) {
                throw new RuntimeException(e);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }, ContextCompat.getMainExecutor(this)); // 在主线程执行 runnable
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

这段流程里的对象职责和调用顺序必须分开理解:

  • ProcessCameraProvider.getInstance(this) 负责申请一个可用的相机管理实例。
  • cameraProvider.addListener(...) 负责等待这个实例真正准备完成。
  • Preview preview = new Preview.Builder().build() 创建的是预览用例。
  • preview.setSurfaceProvider(previewView.getSurfaceProvider()) 把预览用例输出到页面上的 PreviewView
  • CameraSelector.DEFAULT_BACK_CAMERA 指定默认使用后置镜头。
  • imageCapture = new ImageCapture.Builder().build() 创建拍照用例。
  • processCameraProvider.unbindAll() 先解绑旧用例,避免重复绑定冲突。
  • processCameraProvider.bindToLifecycle(...) 则把当前页面生命周期、镜头方向、预览用例和拍照用例统一绑定成一个可工作的相机闭环。

运行页面以后,就能直接看到实时相机预览:

9.2 补齐拍照保存流程

相机能预览以后,下一步才轮到拍照。点击拍照按钮时,最终调用的是 takePhoto()。这一步不能只说"调用拍照接口",因为真正决定结果的是输出位置、执行线程和保存回调。

先把按钮点击事件接到 takePhoto()

java 复制代码
findViewById(R.id.tv_capture).setOnClickListener(view -> {
    takePhoto();
});

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

完整拍照逻辑如下:

java 复制代码
private void takePhoto() {
    if (imageCapture == null) {
        Toast.makeText(this, "拍照异常!", Toast.LENGTH_SHORT).show();
        return;
    }
    File file = new File(getExternalFilesDir(null), System.currentTimeMillis() + ".jpg");

    ImageCapture.OutputFileOptions outputFileOptions = new ImageCapture.OutputFileOptions.Builder(file).build();

    imageCapture.takePicture(outputFileOptions, threadExecutor, new ImageCapture.OnImageSavedCallback() {
        @Override
        public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
            Uri uri = outputFileResults.getSavedUri();
            if (uri != null) {
                File absoluteFile = file.getAbsoluteFile();
                Log.i(TAG, "onImageSaved: absoluteFile:" + absoluteFile);
            }
        }

        @Override
        public void onError(@NonNull ImageCaptureException exception) {
            Log.i(TAG, "onError: " + exception.getMessage(), exception);
        }
    });
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

这段代码里的执行细节需要逐项对齐:

  • imageCapture == null 时直接提示"拍照异常",因为此时拍照用例还没准备好,继续执行只会进入空指针或无效拍照状态。
  • new File(getExternalFilesDir(null), System.currentTimeMillis() + ".jpg") 先在外部私有目录下创建目标文件,文件名用时间戳保证不冲突。
  • new ImageCapture.OutputFileOptions.Builder(file).build() 把输出文件封装成 OutputFileOptions,这是 takePicture() 的第一个必要参数。
  • 第二个参数 threadExecutor 是工作线程,说明拍照保存逻辑不是在主线程里执行;定义在 onCreate 中,声明为成员变量,在一打开拍照页面就创建该线程 threadExecutor = Executors.newSingleThreadExecutor();
  • 第三个参数是保存回调,成功和失败必须分别处理。
  • outputFileResults.getSavedUri() 用来拿到保存结果对应的 uri
  • uri != null 才表示保存成功,此时还继续通过 file.getAbsoluteFile() 打印绝对路径,便于到存储目录验证结果。
  • 失败分支在 onError(...) 中打印异常信息,方便排查保存失败原因。

线程对象在页面创建时就已经准备好:

java 复制代码
threadExecutor = Executors.newSingleThreadExecutor();

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

运行程序点击拍照后,先在日志里确认已经输出保存路径:

再到外部私有存储目录中确认目标图片确实存在:

9.3 在高版本 Android 中通过 MediaStore 保存照片

使用文件路径直存的方式,在很多场景下已经足够。但在更高版本 Android 上,更推荐遵循分区存储规范,通过 MediaStore 来保存媒体文件。

这一步的关键差异不在拍照回调,而在 takePicture(...) 的第一个参数 OutputFileOptions 如何构建。这里先用 ContentValues 描述文件信息,再把这些信息交给 MediaStore 输出配置对象。

java 复制代码
// 创建 ContentValues
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, System.currentTimeMillis() + ".jpg");
contentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg");
contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/CameraXDemo");

// 设置输出选项
ImageCapture.OutputFileOptions outputFileOptions =
        new ImageCapture.OutputFileOptions.Builder(getContentResolver(), MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues).build();

完整写法如下:

java 复制代码
// 创建 ContentValues
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, System.currentTimeMillis() + ".jpg");
contentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg");
contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/CameraXDemo");

// 设置输出选项
ImageCapture.OutputFileOptions outputFileOptions =
        new ImageCapture.OutputFileOptions.Builder(getContentResolver(), MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues).build();

// 拍照
imageCapture.takePicture(outputFileOptions, cameraExecutor, new ImageCapture.OnImageSavedCallback() {
    @Override
    public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
        Uri savedUri = outputFileResults.getSavedUri();
        if (savedUri != null) {
            runOnUiThread(() -> Toast.makeText(CameraActivity.this, "Photo saved to MediaStore: " + savedUri, Toast.LENGTH_SHORT).show());
        } else {
            Log.e(TAG, "Image not saved properly.");
        }
    }

    @Override
    public void onError(@NonNull ImageCaptureException exception) {
        Log.e(TAG, "Photo capture failed: " + exception.getMessage(), exception);
    }
});

这里几项 ContentValues 字段的作用都很直接:

  • DISPLAY_NAME 指定最终文件名。
  • MIME_TYPE 指定文件类型为 image/jpeg
  • RELATIVE_PATH 指定这张图会出现在公共图片目录下的哪个子目录中。

这样生成的输出选项不再依赖开发者自己拼接物理文件路径,而是交给系统媒体库统一管理。

总结流程

在上文,我们实现拍照功能,定义了拍照任务实例 ProcessCameraProvider.getInstance(this),并且对该实例设置点击监听回调,回调的第一个参数为 Runnable 任务,在 Runnable 中:

  • 通过任务对象实例,调用 get 方法获取 ProcessCameraProvider 对象;
  • 先指定了后置相机 CameraSelector.DEFAULT_BACK_CAMERA
  • 同时通过 UI 中的 previewView 通过建造者模式创建 Preview ;
  • 通过建造者模式构建 ImageCapture 拍照对象
  • 接着,解绑 ProcessCameraProvider 之前绑定的生命周期;
  • 然后通过当前页面作为生命周期提供者,依次传入上面三个参数,将 ProcessCameraProvider 对象的生命周期进行绑定;

这样我们就构建好了一个相机对象,此时我们需要实现一个拍照功能:

  • 首先需要定义输出的外部存储空间图片文件,通过该文件构建 OutputFileOptions(高版本安卓系统,需要使用 MediaStore 分段构建 OutputFileOptions)
  • 然后在 onCreate 定义拍照的任务线程
  • 将 OutputFileOptions、任务线程、拍照保存回调作为参数调用 ImageCapture 拍照对象的 takePicture 方法;
  • 在保存成功回调中,通过获取参数的 uri ,uri 不为空则说明保存成功,此时打印图片保存的绝对路径;

9.4 抽离绑定逻辑以支持镜头翻转

前面拍照功能已经能工作,但如果继续把所有逻辑都堆在 startCamera() 里,就没法优雅地切换前后镜头。因为镜头切换的本质不是单改一个值,而是"换一套新的 CameraSelector,然后重新绑定相机用例"。

所以要先把绑定逻辑抽出来,并把 processCameraProvidercameraSelector 提升为成员变量。这样后面一旦镜头方向变化,就能复用同一个绑定方法重新挂载新的生命周期。

java 复制代码
private void startCamera() {
    ListenableFuture<ProcessCameraProvider> cameraProviderFuture = ProcessCameraProvider.
            getInstance(this);
    cameraProviderFuture.addListener(new Runnable() {
        @Override
        public void run() {
            try {
                processCameraProvider = cameraProviderFuture.get();

                //指定后置相机
                cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA;

                bindCamera();

            } catch (ExecutionException e) {
                throw new RuntimeException(e);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }


        }
    }, ContextCompat.getMainExecutor(this));//在主线程执行runnable
}

private void bindCamera() {
    //创建预览
    Preview preview = new Preview.Builder().build();
    //将预览和ui中的previewview绑定
    preview.setSurfaceProvider(previewView.getSurfaceProvider());

    //拍照实例
    imageCapture = new ImageCapture.Builder().build();

    //先解绑之前的
    processCameraProvider.unbindAll();
    //通过cameraProvider进行相机的生命周期管理
    processCameraProvider.bindToLifecycle(CameraPhotoActivity.this,ameraSelector, preview, imageCapture);
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

抽离后的结构更清晰:

  • startCamera() 只负责拿到 ProcessCameraProvider 并指定默认镜头方向。
  • bindCamera() 专门负责根据当前 cameraSelector 重新创建 PreviewImageCapture,再绑定到页面生命周期上。

这样后面无论是首次启动相机,还是镜头翻转后重新绑定,都走同一条逻辑链路。

9.5 切换前后置摄像头

镜头翻转的入口是右上角按钮。点击按钮后,先把 isFrontCamera 取反,再调用 switchCamera()。这里 isFrontCamera 本质上是记录"下一次绑定应该使用哪个镜头方向"的状态变量。

java 复制代码
findViewById(R.id.iv_switch).setOnClickListener(view -> {
    isFrontCamera = !isFrontCamera;
    switchCamera();
});

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

真正的切换动作在 switchCamera() 里完成:

java 复制代码
private void switchCamera() {
    if (isFrontCamera) {
        cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA;
    } else {
        //指定后置相机
        cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA;
    }
    bindCamera();
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

这个方法的两步操作缺一不可:

  • 先根据 isFrontCamera 决定当前要使用 DEFAULT_FRONT_CAMERA 还是 DEFAULT_BACK_CAMERA
  • 再调用 bindCamera(),用新的 cameraSelector 重新绑定整套预览和拍照能力。

切换前是后置镜头:

点击右上角按钮后切换成前置镜头:

切换之后还要继续拍照验证:

  • 先观察预览画面是否确实翻转到了新的镜头方向。
  • 再点击拍照按钮。
  • 最后检查日志中的保存路径和存储目录里的图片结果是否仍然正常。

9.6 释放拍照线程资源

拍照链路中真正需要开发者手动释放的,不是 ProcessCameraProvider,因为它的生命周期已经交给系统和 CameraX 管理。当前页面自己显式创建并持有的,是 threadExecutor 这个单线程执行器,所以页面销毁时只需要把它关掉即可。

java 复制代码
@Override
protected void onDestroy() {
    super.onDestroy();
    
    threadExecutor.shutdown();
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

10. 使用 CameraX 录制视频

拍照功能打通以后,继续往下就是视频录制。它和拍照的思路相似,仍然要先做预览、权限和生命周期绑定,但拍照用例会替换成视频录制用例,录制过程还会多出"开始、结束、状态回调和录音权限"这些额外环节。

10.1 搭建视频录制预览页面

录制页面依然以 PreviewView 为核心,底部放一个录制按钮,右上角继续保留镜头切换按钮的入口。当前布局里 iv_switch 已经预留出来,后续如果继续扩展镜头翻转逻辑,可以直接沿用拍照部分的思路。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".CameraVideoRecordActivity">

    <androidx.camera.view.PreviewView
        android:id="@+id/preview_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />


    <ImageView
        android:id="@+id/iv_record"
        android:layout_width="66dp"
        android:layout_height="66dp"
        android:src="@mipmap/icon_record"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.95" />

    <ImageView
        android:id="@+id/iv_switch"
        android:layout_width="44dp"
        android:layout_height="44dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="16dp"
        android:src="@mipmap/icon_switch_camera"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

项目路径:MediaByJavaProject/app/src/main/res/layout/activity_camera_video_record.xml

Activity 基本结构如下,当前最关键的成员变量是:

  • isRecording:记录当前是否处于录制状态。
  • videoCapture:视频录制用例。
  • executorService:录制回调和后台任务执行线程。
  • recording:当前这一次录制过程对应的 Recording 实例。
java 复制代码
public class CameraVideoRecordActivity extends AppCompatActivity {

    private static final String TAG = "CameraVideoRecordActivi";

    private PreviewView previewView;
    private ImageView ivRecord;
    private boolean isRecording;//是否正在录制的状态
    private VideoCapture<Recorder> videoCapture;
    private ExecutorService executorService;
    private Recording recording;//当前正在录制的实例对象

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_camera_video_record);

        previewView = findViewById(R.id.preview_view);
        ivRecord = findViewById(R.id.iv_record);

        ivRecord.setOnClickListener(view -> {
            //如果是录制状态,点击按钮需要停止
            
        });
    }
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraVideoRecordActivity.java

10.2 绑定视频录制能力并启动预览

视频录制先补两项准备工作:

  • build.gradle 里引入 camera-video 依赖。
  • 清单里声明 RECORD_AUDIO 权限,因为后面的录制逻辑会同时录入声音。
javascript 复制代码
implementation "androidx.camera:camera-video:$camerax_version" // 视频录制相关库

项目路径:MediaByJavaProject/app/build.gradle

xml 复制代码
<uses-permission android:name="android.permission.RECORD_AUDIO" />

项目路径:MediaByJavaProject/app/src/main/AndroidManifest.xml

页面初始化时,录制按钮根据 isRecording 的值决定当前是开始还是停止录制;同时在进入页面后立刻启动相机预览,并申请录音权限。

java 复制代码
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_camera_video_record);

    previewView = findViewById(R.id.preview_view);
    ivRecord = findViewById(R.id.iv_record);

    ivRecord.setOnClickListener(view -> {
        //如果是录制状态,点击按钮需要停止
        if (isRecording) {
            stopRecording();
        } else {
            startRecording();
        }
    });
  	
    
    //申请录音权限
    requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, 100);
}

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    if (requestCode == 100) {
        if (isPermission()) {
            startCamera();
        } else {
            Toast.makeText(this, "权限获取异常!", Toast.LENGTH_SHORT).show();
            finish();
        }
    }
}

在当前工程实现中,相机预览也在 onCreate() 里直接启动:

java 复制代码
startCamera();

//申请录音权限
requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, 100);

真正启动预览时,与拍照场景最大的区别在于第四个绑定对象不再是 ImageCapture,而是 VideoCapture<Recorder>。其中 Recorder 负责录制参数配置,这里用 setQualitySelector(QualitySelector.from(Quality.HD)) 指定录制清晰度为 HD

java 复制代码
//视频录制的实例
Recorder recorder = new Recorder.Builder()
        .setQualitySelector(QualitySelector.from(Quality.HD))//设置清晰度为hd
        .build();

videoCapture = VideoCapture.withOutput(recorder);

cameraProvider.bindToLifecycle(CameraVideoRecordActivity.this,
                            CameraSelector.DEFAULT_BACK_CAMERA,
                            preview,
                            videoCapture
                    );

完整预览启动代码如下:

java 复制代码
private void startCamera() {

    ListenableFuture<ProcessCameraProvider> providerListenableFuture = ProcessCameraProvider.getInstance(this);
    providerListenableFuture.addListener(new Runnable() {
        @Override
        public void run() {

            try {
                ProcessCameraProvider cameraProvider = providerListenableFuture.get();

                //创建预览实例
                Preview preview = new Preview.Builder().build();
                //将预览实例和preViewView绑定
           			preview.setSurfaceProvider(previewView.getSurfaceProvider());

                //视频录制的实例
                Recorder recorder = new Recorder.Builder()
                        .setQualitySelector(QualitySelector.from(Quality.HD))//设置清晰度为hd
                        .build();
                videoCapture = VideoCapture.withOutput(recorder);

                cameraProvider.bindToLifecycle(CameraVideoRecordActivity.this,
                        CameraSelector.DEFAULT_BACK_CAMERA,
                        preview,
                        videoCapture
                );

            } catch (ExecutionException e) {
                throw new RuntimeException(e);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

        }
    }, ContextCompat.getMainExecutor(this));//在主线程执行runnable
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraVideoRecordActivity.java

成功进入页面后,会先看到视频录制预览界面:

10.3 开始录制视频

真正开始录制时,几个对象和状态的关系必须按顺序串起来。

首先要做的不是直接 start(),而是先用 ContentValues 描述这次视频输出的文件名和媒体类型,再用这些信息构建 MediaStoreOutputOptions。也就是说,录制输出目标在启动前就已经确定好了。

java 复制代码
private Recording recording;//当前正在录制的实例对象

private void startRecording() {
    if (isRecording) {
        Toast.makeText(this, "正在录制中!", Toast.LENGTH_SHORT).show();
        return;
    }

    //在contentValues中指定文件名和类型
    ContentValues contentValues = new ContentValues();
    contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "video_" + System.currentTimeMillis() + ".mp4");
    contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4");

    //指定了文件的输出位置
    MediaStoreOutputOptions.Builder builder = new MediaStoreOutputOptions.Builder(getContentResolver(), MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
            .setContentValues(contentValues);
    MediaStoreOutputOptions build = builder.build();

    if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
        Toast.makeText(this, "请先获取录音的权限", Toast.LENGTH_SHORT).show();
        return;
    }
    PendingRecording recording = videoCapture.getOutput().prepareRecording(this, build).withAudioEnabled();//指定需要录制音频

    //启动录制,并且设定录制的回调
    this.recording = recording.start(executorService, new Consumer<VideoRecordEvent>() {
        @Override
        public void accept(VideoRecordEvent recordEvent) {
//                Log.i(TAG, "accept: recordEvent " + recordEvent.getRecordingStats());
            //录制的状态:start pause resume  Finalize Status
            if (recordEvent instanceof VideoRecordEvent.Start) {
                Log.i(TAG, "accept: 开始录制 ");
                //开始录制
                isRecording = true;
                ivRecord.setImageResource(R.mipmap.icon_stop_record);
            } else if (recordEvent instanceof VideoRecordEvent.Finalize) {
                Log.i(TAG, "accept: 录制结束");
                //用户手动终止、遇到异常状态
                isRecording = false;
                ivRecord.setImageResource(R.mipmap.icon_record);
            }
        }
    });
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraVideoRecordActivity.java

这里的关键步骤要逐项拆开看:

  • isRecordingtrue 时直接提示"正在录制中",避免重复开始。
  • ContentValues 里写入文件名和 video/mp4 类型,决定最终媒体库中的这段视频如何保存。
  • MediaStoreOutputOptions 使用建造者,指定 getContentResolver()、外部公有存储 Media 类型的空间、ContentValues 明确指定输出到视频媒体库。
  • ActivityCompat.checkSelfPermission(...) 再次检查录音权限,因为后面会调用 withAudioEnabled() 把声音一起录进去。
  • videoCapture.getOutput().prepareRecording(this, build) 是把当前录制目标装载到 videoCapture
  • withAudioEnabled() 表示这次录制除了画面还会同步录音,没有录音权限就不能执行这一分支。
  • recording.start(executorService, ...) 才是真正开始录制,同时返回一个正在录制中的 Recording 实例,方便后面停止。

录制回调 accept(VideoRecordEvent recordEvent) 也不是只有开始和结束两个静态事件。理论上它会持续收到多种状态:

  • VideoRecordEvent.Start:录制开始。
  • VideoRecordEvent.Pause:录制暂停。
  • VideoRecordEvent.Resume:录制恢复。
  • VideoRecordEvent.Finalize:录制结束,可能是手动停止,也可能是异常结束。
  • VideoRecordEvent.Status:录制过程中的状态更新和统计信息。

当前实现里重点处理了 StartFinalize

  • 收到 Start 时,把 isRecording 置为 true,并把按钮图标切成停止图标。
  • 收到 Finalize 时,把 isRecording 置回 false,并把按钮图标恢复成录制图标。

录制期间,回调会持续输出状态信息:

10.4 结束录制并回收资源

停止录制的入口仍然是底部按钮。这里的按钮分支非常直接:如果当前正在录制,就停止;否则就开始。

java 复制代码
ivRecord.setOnClickListener(view -> {
    //如果是录制状态,点击按钮需要停止
    if (isRecording) {
        stopRecording();
    } else {
        startRecording();
    }
});

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraVideoRecordActivity.java

真正停止录制时,依赖的是前面 startRecording() 返回并保存下来的 Recording 实例:

java 复制代码
private Recording recording;//当前正在录制的实例对象

private void stopRecording() {
    if (recording != null && isRecording) {
        recording.stop();//停止
        recording = null;
    }
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraVideoRecordActivity.java

这里有两个判断条件:

  • recording != null 确保当前确实存在一段正在管理中的录制任务。
  • isRecording 确保当前状态仍然处于录制中,而不是已经完成或已终止。

页面销毁时,当前类里手动创建的资源只有 executorService,因此最后只需要关闭线程池:

java 复制代码
@Override
protected void onDestroy() {
    super.onDestroy();
    executorService.shutdown();
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraVideoRecordActivity.java

录制完成以后,可以直接到系统相册中确认结果视频是否已经生成:

11. 相关代码附录

附录只按职责收拢核心实现,便于回看整体结构。正文中已经出现过的关键代码、图片位置和说明链路,仍以正文为准。

11.1 音频播放相关代码

java 复制代码
public class AudioActivity extends AppCompatActivity {

    private static final String TAG = "AudioActivity";
    private ExoPlayer player;
    private TextView tvDruation;
    private TextView tvTotal;
    private SeekBar seekBar;
    private Handler handler;
    private Runnable runnable;
    private PlayerView playerView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_audio);

        playerView = findViewById(R.id.player_view);
        tvDruation = findViewById(R.id.tv_druation);
        tvTotal = findViewById(R.id.tv_total);
        seekBar = findViewById(R.id.seek_bar);

        seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
            @Override
            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
                if (fromUser) {
                    player.seekTo(progress);
                }
            }

            @Override
            public void onStartTrackingTouch(SeekBar seekBar) {}

            @Override
            public void onStopTrackingTouch(SeekBar seekBar) {}
        });

        findViewById(R.id.btn_prepare).setOnClickListener(view -> {
            MediaItem mediaItem = MediaItem.fromUri(getNetWorkUri());
            player.setMediaItem(mediaItem);
            player.prepare();
        });

        findViewById(R.id.btn_play).setOnClickListener(view -> {
            if (!player.isPlaying()) {
                player.play();
            }
        });

        findViewById(R.id.btn_pause).setOnClickListener(view -> {
            if (player.isPlaying()) {
                player.pause();
            }
        });

        findViewById(R.id.btn_stop).setOnClickListener(view -> player.stop());
        initPlayer();
    }

    private void initPlayer() {
        player = new ExoPlayer.Builder(this).build();
        player.addListener(new Player.Listener() {
            @Override
            public void onPlaybackStateChanged(int playbackState) {
                switch (playbackState) {
                    case Player.STATE_IDLE:
                        Log.i(TAG, "onPlaybackStateChanged: 播放器空闲");
                        break;
                    case Player.STATE_BUFFERING:
                        Log.i(TAG, "onPlaybackStateChanged: 缓冲...");
                        break;
                    case Player.STATE_READY:
                        long duration = player.getDuration();
                        seekBar.setMax((int) duration);
                        tvTotal.setText(formatTime(duration));
                        Log.i(TAG, "onPlaybackStateChanged: 播放器准备就绪,当前资源总时长:" + duration);
                        break;
                    case Player.STATE_ENDED:
                        Log.i(TAG, "onPlaybackStateChanged: 播放器播放完毕");
                        break;
                }
                Log.i(TAG, "onPlaybackStateChanged: playbackState = " + playbackState);
            }

            @Override
            public void onIsPlayingChanged(boolean isPlaying) {
                Log.i(TAG, "onIsPlayingChanged: isPlaying = " + isPlaying);
            }

            @Override
            public void onPlayerError(PlaybackException error) {
                Log.i(TAG, "onPlayerError: error = " + error);
                if (error.errorCode == PlaybackException.ERROR_CODE_TIMEOUT) {
                    Toast.makeText(AudioActivity.this, "加载超时", Toast.LENGTH_SHORT).show();
                }
            }
        });

        playerView.setPlayer(player);

        handler = new Handler();
        runnable = new Runnable() {
            @Override
            public void run() {
                long currentPosition = player.getCurrentPosition();
                seekBar.setProgress((int) currentPosition);
                tvDruation.setText(formatTime(currentPosition));
                handler.postDelayed(this, 1000);
            }
        };
        handler.post(runnable);
    }
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/AudioActivity.java

11.2 视频播放相关代码

java 复制代码
public class VideoActivity extends AppCompatActivity {

    private static final String TAG = "VideoActivity";
    private PlayerView playerView;
    private ExoPlayer player;
    private ImageView imageContrl;
    private SeekBar seekBar;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_video);

        imageContrl = findViewById(R.id.iv_contrl);
        seekBar = findViewById(R.id.seek_bar);
        playerView = findViewById(R.id.player_view);
        initPlayer();

        ArrayList<MediaItem> mediaItems = new ArrayList<>();
        mediaItems.add(MediaItem.fromUri(Uri.parse("http://titok.fzqq.fun//uploads/20241010/b054fb0478e8c3d95a518cf5e1b67163.mp4")));
        mediaItems.add(MediaItem.fromUri(Uri.parse("http://titok.fzqq.fun//uploads/20241010/5043fbca9fabb94fba2668c4a827d3a5.mp4")));
        mediaItems.add(MediaItem.fromUri(Uri.parse("http://titok.fzqq.fun//uploads/20241010/02577b1fabe3cbbdd75c3f6a02434efd.mp4")));

        player.setMediaItems(mediaItems);
        player.prepare();

        imageContrl.setOnClickListener(view -> {
            if (player.isPlaying()) {
                player.pause();
                imageContrl.setImageResource(R.mipmap.icon_bofang);
            } else {
                player.play();
                imageContrl.setImageResource(R.mipmap.icon_zanting);
            }
        });
    }
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/VideoActivity.java

11.3 CameraX 拍照相关代码

java 复制代码
public class CameraPhotoActivity extends AppCompatActivity {
    private static final String TAG = "CameraPhotoActivity";
    private PreviewView previewView;
    private ImageCapture imageCapture;
    private ExecutorService threadExecutor;
    private ProcessCameraProvider processCameraProvider;
    private CameraSelector cameraSelector;
    private boolean isFrontCamera;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_camera_photo);

        previewView = findViewById(R.id.preview_view);

        findViewById(R.id.tv_capture).setOnClickListener(view -> takePhoto());
        findViewById(R.id.iv_switch).setOnClickListener(view -> {
            isFrontCamera = !isFrontCamera;
            switchCamera();
        });

        if (isPermission()) {
            startCamera();
        } else {
            ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, 100);
        }

        threadExecutor = Executors.newSingleThreadExecutor();
    }

    private void switchCamera() {
        if (isFrontCamera) {
            cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA;
        } else {
            cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA;
        }
        bindCamera();
    }

    private void takePhoto() {
        if (imageCapture == null) {
            Toast.makeText(this, "拍照异常!", Toast.LENGTH_SHORT).show();
            return;
        }
        File file = new File(getExternalFilesDir(null), System.currentTimeMillis() + ".jpg");
        ImageCapture.OutputFileOptions outputFileOptions = new ImageCapture.OutputFileOptions.Builder(file).build();
        imageCapture.takePicture(outputFileOptions, threadExecutor, new ImageCapture.OnImageSavedCallback() {
            @Override
            public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
                Uri uri = outputFileResults.getSavedUri();
                if (uri != null) {
                    File absoluteFile = file.getAbsoluteFile();
                    Log.i(TAG, "onImageSaved: absoluteFile:" + absoluteFile);
                }
            }

            @Override
            public void onError(@NonNull ImageCaptureException exception) {
                Log.i(TAG, "onError: " + exception.getMessage(), exception);
            }
        });
    }
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraPhotoActivity.java

11.4 CameraX 视频录制相关代码

java 复制代码
public class CameraVideoRecordActivity extends AppCompatActivity {

    private static final String TAG = "CameraVideoRecordActivi";
    private PreviewView previewView;
    private ImageView ivRecord;
    private boolean isRecording;
    private VideoCapture<Recorder> videoCapture;
    private ExecutorService executorService;
    private Recording recording;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_camera_video_record);

        previewView = findViewById(R.id.preview_view);
        ivRecord = findViewById(R.id.iv_record);

        ivRecord.setOnClickListener(view -> {
            if (isRecording) {
                stopRecording();
            } else {
                startRecording();
            }
        });

        startCamera();
        executorService = Executors.newSingleThreadExecutor();
        requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, 100);
    }

    private void startRecording() {
        if (isRecording) {
            Toast.makeText(this, "正在录制中!", Toast.LENGTH_SHORT).show();
            return;
        }

        ContentValues contentValues = new ContentValues();
        contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "video_" + System.currentTimeMillis() + ".mp4");
        contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4");

        MediaStoreOutputOptions build = new MediaStoreOutputOptions.Builder(
                getContentResolver(),
                MediaStore.Video.Media.EXTERNAL_CONTENT_URI
        ).setContentValues(contentValues).build();

        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            Toast.makeText(this, "请先获取录音的权限", Toast.LENGTH_SHORT).show();
            return;
        }

        PendingRecording pendingRecording = videoCapture.getOutput().prepareRecording(this, build).withAudioEnabled();
        this.recording = pendingRecording.start(executorService, recordEvent -> {
            if (recordEvent instanceof VideoRecordEvent.Start) {
                isRecording = true;
                ivRecord.setImageResource(R.mipmap.icon_stop_record);
            } else if (recordEvent instanceof VideoRecordEvent.Finalize) {
                isRecording = false;
                ivRecord.setImageResource(R.mipmap.icon_record);
            }
        });
    }

    private void stopRecording() {
        if (recording != null && isRecording) {
            recording.stop();
            recording = null;
        }
    }
}

项目路径:MediaByJavaProject/app/src/main/java/com/ls/mediabyjavaproject/CameraVideoRecordActivity.java

相关推荐
EasyGBS4 小时前
国密GB35114协议+国标GB28181平台EasyGBS双重保障筑牢安防视频安全防线
安全·https·音视频
AI创界者4 小时前
Gemini/Grok/ChatGPT 安卓版安装教程:手机 AI 助手快速上手指南
android·chatgpt·智能手机
今夕资源网4 小时前
VSE硬字幕提取工具 Video subtitle extractor 视频生成srt字幕文件 含详细使用方法
音视频·视频生成字幕·视频生成srt·srt字幕文件·视频硬字幕提取·硬字幕提取·字幕文件提取
中议视控15 小时前
RTSP和RTSM编码推送软件让中控系统控制实现可视化播控
网络·分布式·物联网·5g·音视频
gregmankiw15 小时前
Nemotron架构(Mamba3+Transformer+Moe)
android·深度学习·transformer
xianjian091217 小时前
MySQL 的 INSERT(插入数据)详解
android·数据库·mysql
欧简墨18 小时前
kotlin Android Extensions插件迁移到viewbinding总结
android·trae
货拉拉技术18 小时前
优雅解决Android app后台悬浮窗权限问题
android
用户693717500138419 小时前
Android 手机终于能当电脑用了
android·前端