前言
这篇内容围绕 Android 中最常见的几类媒体能力展开:先用 Jetpack Media3 完成音频播放、状态监听、进度同步和视频播放,再补充 SoundPool 这类短音效场景的适用方式,最后接入 CameraX 完成相机预览、拍照、镜头切换与视频录制。全文按工程实现的推进顺序展开,重点不是只给出最终代码,而是把每一步为什么要写、关键对象如何配合、状态变化发生在什么节点,以及结果该如何验证交代清楚。
播放器部分的核心在于三件事:把媒体源正确装载到 ExoPlayer、让界面状态随着播放器状态和播放进度同步变化、并在页面进入前后台时正确处理资源。相机部分的核心也同样明确:先处理权限与预览,再把 ProcessCameraProvider、PreviewView、ImageCapture、VideoCapture 这些对象组织成稳定的生命周期闭环,在此基础上继续扩展拍照、镜头翻转和视频录制。
目录
- [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 的循环推送方式,几个实现细节要一起理解:
handler和runnable被定义成成员变量,不是局部变量。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;
}
}
这里每个参数都有明确作用:
soundId是load()返回的音频标识。- 左右声道音量决定播放强度。
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,然后重新绑定相机用例"。
所以要先把绑定逻辑抽出来,并把 processCameraProvider 和 cameraSelector 提升为成员变量。这样后面一旦镜头方向变化,就能复用同一个绑定方法重新挂载新的生命周期。
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重新创建Preview和ImageCapture,再绑定到页面生命周期上。
这样后面无论是首次启动相机,还是镜头翻转后重新绑定,都走同一条逻辑链路。
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
这里的关键步骤要逐项拆开看:
isRecording为true时直接提示"正在录制中",避免重复开始。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:录制过程中的状态更新和统计信息。
当前实现里重点处理了 Start 和 Finalize:
- 收到
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