ExoPlayer HLS加密密钥获取:自定义密钥服务器实现
【免费下载链接】ExoPlayer 项目地址: https://gitcode.com/gh_mirrors/ex/ExoPlayer
1. HLS加密播放痛点与解决方案
你是否在集成HLS加密流时遇到过这些问题:密钥服务器认证逻辑复杂、多CDN密钥分发不一致、密钥请求需要特殊签名?ExoPlayer虽然原生支持HLS加密播放,但面对企业级密钥管理需求时,默认实现往往难以满足定制化需求。本文将通过5个实战步骤+完整代码示例,教你实现自定义密钥获取逻辑,解决90%的HLS加密播放难题。
读完本文你将掌握:
- HLS加密播放的核心工作流程
- 自定义密钥获取器的实现原理
- 密钥请求签名与认证处理
- 密钥缓存与更新策略
- 完整的异常处理方案
2. HLS加密播放原理
2.1 加密播放基本流程
HLS(HTTP Live Streaming,HTTP直播流)加密播放依赖于AES-128加密机制,其核心流程如下:
2.2 加密媒体m3u8文件结构
典型的加密HLS m3u8文件包含密钥信息标签#EXT-X-KEY:
#EXTM3U
#EXT-X-TARGETDURATION:10
#EXT-X-VERSION:3
#EXT-X-KEY:METHOD=AES-128,URI="https://keyserver.example.com/key?id=123",IV=0x1234567890ABCDEF1234567890ABCDEF
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
segment_0.ts
#EXTINF:10.0,
segment_1.ts
其中关键参数说明:
METHOD: 加密算法,通常为AES-128URI: 密钥获取地址IV: 初始化向量(可选),16字节16进制数
3. ExoPlayer密钥获取架构
3.1 默认密钥获取流程
ExoPlayer默认通过DefaultHlsKeyManager处理密钥获取,其类关系如下:
3.2 自定义密钥获取的切入点
ExoPlayer通过HlsKeyManager接口提供密钥管理能力,自定义实现需关注以下核心方法:
public interface HlsKeyManager {
/**
* 获取加密密钥
* @param keyUri 密钥URL(来自m3u8的#EXT-X-KEY)
* @param keyFormat 密钥格式
* @param keyFormatVersions 支持的密钥版本
* @param initializationVector IV向量
* @return 包含密钥的KeyResult对象
* @throws IOException 如果密钥获取失败
*/
KeyResult getEncryptionKey(
Uri keyUri,
@Nullable String keyFormat,
@Nullable List<String> keyFormatVersions,
@Nullable byte[] initializationVector)
throws IOException;
}
3. 自定义密钥管理器实现
3.1 基础实现框架
自定义密钥管理器需要继承HlsKeyManager接口,实现核心的密钥获取逻辑。以下是基础框架代码:
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.source.hls.HlsKeyManager;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.util.Util;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.List;
import java.util.Map;
public class CustomHlsKeyManager implements HlsKeyManager {
private final DataSource.Factory dataSourceFactory;
public CustomHlsKeyManager(DataSource.Factory dataSourceFactory) {
this.dataSourceFactory = dataSourceFactory;
}
@Override
public KeyResult getEncryptionKey(Uri keyUri, String keyFormat,
List<String> keyFormatVersions, byte[] initializationVector)
throws IOException {
// 1. 处理密钥URL(可能需要重写或修改)
Uri adjustedKeyUri = adjustKeyUri(keyUri);
// 2. 创建带认证信息的请求
DataSpec dataSpec = createKeyDataSpec(adjustedKeyUri);
// 3. 执行密钥请求
byte[] keyData = executeKeyRequest(dataSpec);
// 4. 处理密钥数据(可能需要解码或解密)
byte[] encryptionKey = processKeyData(keyData);
// 5. 返回密钥结果
return new KeyResult(encryptionKey, initializationVector, C.KEY_TYPE_AES);
}
// 密钥URL调整(如替换域名、添加参数等)
private Uri adjustKeyUri(Uri originalUri) {
// 示例: 添加时间戳参数防止缓存
return originalUri.buildUpon()
.appendQueryParameter("timestamp", String.valueOf(System.currentTimeMillis() / 1000))
.build();
}
// 创建密钥请求数据规范
private DataSpec createKeyDataSpec(Uri keyUri) {
return new DataSpec.Builder()
.setUri(keyUri)
.setHttpMethod(DataSpec.HTTP_METHOD_GET)
.addHeader("Authorization", createAuthHeader())
.addHeader("User-Agent", "ExoPlayer-CustomKeyManager/2.18.1")
.build();
}
// 创建认证头(示例: HMAC签名)
private String createAuthHeader() {
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
String nonce = Util.getRandomHexString(16);
String signature = generateHmacSignature(timestamp, nonce);
return "CustomAuth timestamp=\"" + timestamp + "\", nonce=\"" + nonce + "\", signature=\"" + signature + "\"";
}
// 生成HMAC签名(实际项目需替换为真实实现)
private String generateHmacSignature(String timestamp, String nonce) {
// 示例: 使用密钥对时间戳和随机数进行HMAC-SHA256签名
String secretKey = "your-secret-key"; // 实际项目中应安全存储
String data = timestamp + nonce;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey.getBytes(), "HmacSHA256"));
byte[] signatureBytes = mac.doFinal(data.getBytes());
return Base64.encodeToString(signatureBytes, Base64.NO_WRAP);
} catch (Exception e) {
throw new RuntimeException("Failed to generate signature", e);
}
}
// 执行密钥请求
private byte[] executeKeyRequest(DataSpec dataSpec) throws IOException {
DataSource dataSource = dataSourceFactory.createDataSource();
try {
dataSource.open(dataSpec);
int responseCode = dataSource.getResponseCode();
// 处理HTTP响应码
if (responseCode != HttpURLConnection.HTTP_OK) {
throw new IOException("Key request failed with code: " + responseCode);
}
// 读取密钥数据
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = dataSource.read(buffer, 0, buffer.length)) != C.RESULT_END_OF_INPUT) {
output.write(buffer, 0, bytesRead);
}
return output.toByteArray();
} finally {
Util.closeQuietly(dataSource);
}
}
// 处理密钥数据(如解密或格式转换)
private byte[] processKeyData(byte[] rawKeyData) {
// 示例: 如果密钥是Base64编码的,需要解码
return Base64.decode(rawKeyData, Base64.NO_WRAP);
}
}
3.2 密钥缓存策略实现
为减少密钥服务器负载并提高播放流畅度,实现密钥缓存至关重要:
public class CustomHlsKeyManager implements HlsKeyManager {
// 内存缓存(使用LRU策略,限制最大缓存100个密钥)
private final LruCache<String, CachedKey> keyCache;
// 缓存超时时间(5分钟)
private static final long KEY_CACHE_TTL_MS = 5 * 60 * 1000;
public CustomHlsKeyManager(DataSource.Factory dataSourceFactory) {
this.dataSourceFactory = dataSourceFactory;
// 初始化LRU缓存,最大缓存大小为100
this.keyCache = new LruCache<>(100);
}
@Override
public KeyResult getEncryptionKey(Uri keyUri, String keyFormat,
List<String> keyFormatVersions, byte[] initializationVector)
throws IOException {
// 生成缓存键(使用密钥URL作为唯一标识)
String cacheKey = generateCacheKey(keyUri);
// 检查缓存
CachedKey cachedKey = keyCache.get(cacheKey);
if (cachedKey != null && !isKeyExpired(cachedKey)) {
// 返回缓存的密钥
return new KeyResult(cachedKey.keyData, initializationVector, C.KEY_TYPE_AES);
}
// 缓存未命中,执行网络请求获取密钥
byte[] encryptionKey = fetchEncryptionKey(keyUri, keyFormat, keyFormatVersions);
// 存入缓存
keyCache.put(cacheKey, new CachedKey(encryptionKey, System.currentTimeMillis()));
return new KeyResult(encryptionKey, initializationVector, C.KEY_TYPE_AES);
}
// 生成缓存键
private String generateCacheKey(Uri keyUri) {
// 移除可能变化的查询参数(如timestamp)后作为缓存键
Uri.Builder uriBuilder = keyUri.buildUpon();
Set<String> queryParameterNames = keyUri.getQueryParameterNames();
for (String paramName : queryParameterNames) {
if ("timestamp".equals(paramName) || "nonce".equals(paramName)) {
uriBuilder.removeQueryParameter(paramName);
}
}
return uriBuilder.build().toString();
}
// 检查密钥是否过期
private boolean isKeyExpired(CachedKey cachedKey) {
return System.currentTimeMillis() - cachedKey.timestamp > KEY_CACHE_TTL_MS;
}
// 从网络获取密钥(实际实现见3.1节)
private byte[] fetchEncryptionKey(Uri keyUri, String keyFormat, List<String> keyFormatVersions) throws IOException {
// 实现见3.1节中的密钥获取逻辑
// ...
}
// 缓存键数据类
private static class CachedKey {
final byte[] keyData;
final long timestamp;
CachedKey(byte[] keyData, long timestamp) {
this.keyData = keyData;
this.timestamp = timestamp;
}
}
// 清除缓存方法(供外部调用)
public void clearCache() {
keyCache.evictAll();
}
// 按URL移除缓存项
public void removeFromCache(Uri keyUri) {
String cacheKey = generateCacheKey(keyUri);
keyCache.remove(cacheKey);
}
}
4. 异常处理策略
密钥获取过程中可能遇到多种异常情况,完善的异常处理机制是确保播放稳定性的关键:
4.1 异常类型与处理方案
| 异常类型 | 可能原因 | 处理策略 | 重试机制 |
|---|---|---|---|
| IOException | 网络连接失败 | 切换备用密钥服务器 | 指数退避重试(最多3次) |
| HttpException(401) | 认证失败 | 重新获取认证令牌 | 立即重试(1次) |
| HttpException(403) | 权限不足 | 提示用户购买权限 | 不重试 |
| HttpException(404) | 密钥不存在 | 播放错误提示 | 不重试 |
| HttpException(5xx) | 服务器错误 | 切换备用服务器 | 延迟重试(最多2次) |
| InvalidKeyException | 密钥格式错误 | 清除缓存并重试 | 强制刷新重试(1次) |
| TimeoutException | 请求超时 | 增大超时时间 | 立即重试(1次) |
4.2 异常处理代码实现
@Override
public KeyResult getEncryptionKey(Uri keyUri, String keyFormat,
List<String> keyFormatVersions, byte[] initializationVector)
throws IOException {
// 重试配置
int maxRetries = 3;
long initialBackoffMs = 1000; // 初始退避时间
List<Uri> fallbackKeyUris = getFallbackKeyUris(keyUri); // 获取备用密钥服务器列表
for (int attempt = 0; attempt <= maxRetries; attempt++) {
Uri currentUri = attempt < fallbackKeyUris.size() ? fallbackKeyUris.get(attempt) : keyUri;
try {
// 尝试获取密钥
return attemptToGetEncryptionKey(currentUri, keyFormat, keyFormatVersions, initializationVector);
} catch (HttpException e) {
int responseCode = e.responseCode;
if (responseCode == 401) {
// 认证失败: 刷新令牌后立即重试一次
if (refreshAuthToken()) {
return attemptToGetEncryptionKey(currentUri, keyFormat, keyFormatVersions, initializationVector);
}
throw new IOException("Authentication failed after token refresh", e);
} else if (responseCode == 403) {
// 权限不足: 抛出特定异常供上层处理
throw new PermissionDeniedException("No permission to access content", e);
} else if (responseCode >= 500 && responseCode < 600 && attempt < maxRetries) {
// 服务器错误: 退避重试
long backoffTime = initialBackoffMs * (1 << attempt); // 指数退避
Thread.sleep(backoffTime);
continue;
} else {
// 其他HTTP错误: 不重试
throw new IOException("Key request failed with code: " + responseCode, e);
}
} catch (IOException e) {
// 网络错误: 退避重试
if (attempt < maxRetries) {
long backoffTime = initialBackoffMs * (1 << attempt);
Thread.sleep(backoffTime);
continue;
}
throw new IOException("Failed to fetch key after " + (maxRetries + 1) + " attempts", e);
} catch (InvalidKeyException e) {
// 密钥无效: 清除缓存后重试一次
removeFromCache(currentUri);
if (attempt == 0) {
continue;
}
throw new IOException("Invalid key after refresh", e);
}
}
throw new IOException("Maximum retry attempts reached");
}
// 刷新认证令牌
private boolean refreshAuthToken() {
try {
// 实现令牌刷新逻辑
// ...
return true; // 刷新成功
} catch (Exception e) {
return false; // 刷新失败
}
}
// 获取备用密钥服务器列表
private List<Uri> getFallbackKeyUris(Uri originalUri) {
List<Uri> fallbackUris = new ArrayList<>();
// 添加备用服务器URL
String originalHost = originalUri.getHost();
if ("keyserver1.example.com".equals(originalHost)) {
fallbackUris.add(originalUri.buildUpon().authority("keyserver2.example.com").build());
fallbackUris.add(originalUri.buildUpon().authority("keyserver3.example.com").build());
}
return fallbackUris;
}
// 单次尝试获取密钥
private KeyResult attemptToGetEncryptionKey(Uri keyUri, String keyFormat,
List<String> keyFormatVersions, byte[] initializationVector)
throws IOException, InvalidKeyException {
// 实现具体的密钥获取逻辑
// ...
}
// 自定义权限不足异常
public static class PermissionDeniedException extends IOException {
public PermissionDeniedException(String message, Throwable cause) {
super(message, cause);
}
}
5. 集成到ExoPlayer
5.1 配置HlsMediaSource.Factory
// 创建自定义密钥管理器
CustomHlsKeyManager keyManager = new CustomHlsKeyManager(
new DefaultHttpDataSource.Factory()
.setUserAgent("Your-App-Name/1.0.0")
.setConnectTimeoutMs(5000)
.setReadTimeoutMs(5000)
);
// 创建HLS媒体源工厂
HlsMediaSource.Factory hlsMediaSourceFactory = new HlsMediaSource.Factory(
new DefaultHttpDataSource.Factory()
.setUserAgent("Your-App-Name/1.0.0")
)
// 设置自定义密钥管理器
.setHlsKeyManager(keyManager)
// 设置密钥请求超时
.setKeyRequestTimeoutMs(10_000)
// 设置最大重试次数
.setMaxLoadRetryCount(3);
// 创建ExoPlayer实例
ExoPlayer player = new ExoPlayer.Builder(context)
.setMediaSourceFactory(hlsMediaSourceFactory)
.build();
// 准备播放加密HLS流
Uri hlsUri = Uri.parse("https://your-cdn.example.com/stream/encrypted.m3u8");
MediaItem mediaItem = MediaItem.fromUri(hlsUri);
player.setMediaItem(mediaItem);
player.prepare();
player.play();
5.2 结合DrmSessionManager使用
对于需要DRM保护的高级场景,可结合DrmSessionManager使用:
// 创建DRM配置
ExoMediaDrm.Config drmConfig = new ExoMediaDrm.Config.Builder()
.setUuidAndExoMediaDrmProvider(C.WIDEVINE_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER)
.build();
// 创建DRM会话管理器
DefaultDrmSessionManager drmSessionManager = new DefaultDrmSessionManager.Builder()
.setDrmConfig(drmConfig)
.build();
// 创建自定义密钥管理器(带DRM支持)
DrmEnabledHlsKeyManager keyManager = new DrmEnabledHlsKeyManager(
new DefaultHttpDataSource.Factory().setUserAgent("Your-App-Name/1.0.0"),
drmSessionManager
);
// 创建HLS媒体源工厂(同上)
HlsMediaSource.Factory hlsMediaSourceFactory = new HlsMediaSource.Factory(...)
.setHlsKeyManager(keyManager);
6. 高级优化策略
6.1 预加载密钥
为减少播放启动延迟,可在播放前预加载密钥:
// 预加载密钥方法
public void preloadKey(Uri keyUri) {
new AsyncTask<Void, Void, Boolean>() {
@Override
protected Boolean doInBackground(Void... params) {
try {
// 调用密钥获取逻辑,但不实际使用密钥
getEncryptionKey(keyUri, null, null, null);
return true;
} catch (Exception e) {
// 预加载失败,不影响主流程
Log.w("CustomHlsKeyManager", "Preload key failed", e);
return false;
}
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
// 使用示例
Uri hlsUri = Uri.parse("https://your-cdn.example.com/stream/encrypted.m3u8");
Uri keyUri = Uri.parse("https://keyserver.example.com/key?id=123");
// 预加载密钥
keyManager.preloadKey(keyUri);
// 延迟一段时间后再开始播放(让预加载有时间完成)
handler.postDelayed(() -> {
MediaItem mediaItem = MediaItem.fromUri(hlsUri);
player.setMediaItem(mediaItem);
player.prepare();
player.play();
}, 1000); // 延迟1秒
6.2 密钥更新策略
对于长期播放的加密流,密钥可能定期轮换,需要实现密钥自动更新:
// 密钥更新监听器
public interface KeyUpdateListener {
void onKeyUpdated(Uri keyUri, byte[] newKeyData);
}
// 密钥更新实现
private ScheduledExecutorService keyUpdateScheduler = Executors.newSingleThreadScheduledExecutor();
private Map<String, ScheduledFuture<?>> keyUpdateTasks = new HashMap<>();
// 安排密钥定期更新
public void scheduleKeyUpdate(Uri keyUri, long intervalMs) {
String cacheKey = generateCacheKey(keyUri);
// 取消已存在的更新任务
cancelKeyUpdate(keyUri);
// 创建新的更新任务
ScheduledFuture<?> future = keyUpdateScheduler.scheduleAtFixedRate(() -> {
try {
// 获取新密钥
byte[] newKeyData = fetchEncryptionKey(keyUri, null, null);
// 更新缓存
keyCache.put(cacheKey, new CachedKey(newKeyData, System.currentTimeMillis()));
// 通知监听器密钥已更新
if (keyUpdateListener != null) {
keyUpdateListener.onKeyUpdated(keyUri, newKeyData);
}
} catch (Exception e) {
Log.e("CustomHlsKeyManager", "Key update failed", e);
}
}, intervalMs, intervalMs, TimeUnit.MILLISECONDS);
keyUpdateTasks.put(cacheKey, future);
}
// 取消密钥更新任务
public void cancelKeyUpdate(Uri keyUri) {
String cacheKey = generateCacheKey(keyUri);
ScheduledFuture<?> future = keyUpdateTasks.remove(cacheKey);
if (future != null) {
future.cancel(false);
}
}
// 取消所有密钥更新任务
public void cancelAllKeyUpdates() {
for (ScheduledFuture<?> future : keyUpdateTasks.values()) {
future.cancel(false);
}
keyUpdateTasks.clear();
}
7. 完整使用示例
以下是在Activity中集成自定义密钥管理器的完整示例:
public class HlsEncryptedPlayerActivity extends AppCompatActivity implements Player.Listener {
private Player player;
private CustomHlsKeyManager customKeyManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_player);
// 创建自定义密钥管理器
customKeyManager = new CustomHlsKeyManager(
new DefaultHttpDataSource.Factory()
.setUserAgent("HlsEncryptedPlayer/1.0.0")
.setConnectTimeoutMs(5000)
.setReadTimeoutMs(5000)
);
// 设置密钥更新监听器
customKeyManager.setKeyUpdateListener((keyUri, newKeyData) -> {
Log.d("HlsPlayer", "Key updated for URI: " + keyUri);
});
// 创建HLS媒体源工厂
HlsMediaSource.Factory mediaSourceFactory = new HlsMediaSource.Factory(
new DefaultHttpDataSource.Factory()
.setUserAgent("HlsEncryptedPlayer/1.0.0")
)
.setHlsKeyManager(customKeyManager);
// 创建ExoPlayer实例
player = new ExoPlayer.Builder(this)
.setMediaSourceFactory(mediaSourceFactory)
.build();
// 绑定播放器视图
PlayerView playerView = findViewById(R.id.player_view);
playerView.setPlayer(player);
// 设置播放器监听器
player.addListener(this);
// 准备播放加密HLS流
String hlsUrl = "https://your-cdn.example.com/stream/encrypted.m3u8";
Uri hlsUri = Uri.parse(hlsUrl);
// 预加载密钥
customKeyManager.preloadKey(Uri.parse("https://keyserver.example.com/key?id=123"));
// 安排密钥每30分钟更新一次
customKeyManager.scheduleKeyUpdate(Uri.parse("https://keyserver.example.com/key?id=123"), 30 * 60 * 1000);
// 准备播放
MediaItem mediaItem = MediaItem.fromUri(hlsUri);
player.setMediaItem(mediaItem);
player.prepare();
player.play();
}
@Override
public void onPlayerError(PlaybackException error) {
// 处理播放错误
Throwable cause = error.getCause();
if (cause instanceof CustomHlsKeyManager.PermissionDeniedException) {
// 显示权限不足对话框
showPermissionDeniedDialog();
} else if (cause instanceof IOException) {
// 显示网络错误对话框
showNetworkErrorDialog();
} else {
// 显示通用错误对话框
showErrorDialog(error.getMessage());
}
}
private void showPermissionDeniedDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle("播放失败")
.setMessage("您没有足够的权限播放此内容,请购买或订阅后再试。")
.setPositiveButton("前往购买", (dialog, which) -> {
// 打开购买页面
// ...
})
.setNegativeButton("取消", null)
.show();
}
private void showNetworkErrorDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle("网络错误")
.setMessage("无法连接到密钥服务器,请检查网络连接后重试。")
.setPositiveButton("重试", (dialog, which) -> {
// 清除缓存并重试
customKeyManager.clearCache();
player.prepare();
player.play();
})
.setNegativeButton("取消", null)
.show();
}
private void showErrorDialog(String message) {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle("播放错误")
.setMessage(message)
.setPositiveButton("确定", null)
.show();
}
@Override
protected void onDestroy() {
super.onDestroy();
// 释放播放器资源
player.release();
// 取消密钥更新任务
customKeyManager.cancelAllKeyUpdates();
}
}
8. 总结与最佳实践
8.1 核心要点回顾
自定义HLS密钥管理器实现需关注以下核心要点:
- 密钥获取流程:实现
HlsKeyManager接口,处理密钥URL调整、认证、请求执行和密钥处理 - 缓存策略:使用LRU缓存减少服务器负载,设置合理的缓存过期时间
- 异常处理:针对不同异常类型实现差异化的重试和恢复策略
- 安全措施:保护密钥和认证信息,使用安全的签名算法
- 性能优化:实现密钥预加载和定期更新,减少播放延迟
8.2 最佳实践建议
- 密钥服务器选择:部署多区域密钥服务器,实现故障自动切换
- 认证机制:使用短期有效令牌+签名机制,避免密钥泄露风险
- 缓存管理:根据密钥更新频率调整缓存时间,避免使用过期密钥
- 监控与日志:记录密钥获取性能指标和错误日志,便于问题排查
- 兼容性测试:在不同Android版本和设备上测试密钥获取逻辑
8.3 扩展应用场景
自定义密钥管理器还可应用于以下高级场景:
- 多DRM系统集成:同时支持Widevine、PlayReady等多种DRM方案
- 密钥旋转:实现动态密钥轮换,增强内容安全性
- P2P密钥分发:结合P2P技术减少密钥服务器负载
- 离线密钥管理:支持预下载密钥用于离线播放
- 硬件安全模块:使用硬件安全区域存储和处理密钥
通过本文介绍的自定义密钥管理器实现方案,你可以灵活应对各种复杂的HLS加密播放需求,为用户提供安全、流畅的加密媒体播放体验。
点赞+收藏+关注,获取更多ExoPlayer高级应用技巧。下期预告:《ExoPlayer低延迟HLS直播优化实践》。
【免费下载链接】ExoPlayer 项目地址: https://gitcode.com/gh_mirrors/ex/ExoPlayer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



