30. 综合实战:仿抖音相机:项目架构设计、UI交互实现、录制与编辑功能集成、发布与分享。
终于到了最后一章。说实话,写到这里我自己也有点感慨。前面29章我们一步步把摄像头采集、编码、美颜、滤镜、音频处理这些零散的知识点都啃下来了。现在,是时候把它们串起来,做一个真正能跑、能录、能编辑、能分享的App了。
目标很明确:仿抖音相机。但别误会,我们不是要抄抖音,而是要理解它背后的架构思路。抖音那种流畅的录制体验、即时的编辑反馈、一键发布的流程,背后其实是一套非常成熟的设计模式。
我个人习惯,做这种综合项目前,先画一张架构图。把脑子里的想法倒出来,比直接写代码要靠谱得多。
这张图我画了三层:UI层、业务逻辑层、数据层。每一层各司其职,互不越界。你在抖音上看到的那些炫酷效果,其实都是这三层协作的结果。
30.1 项目架构设计:为什么是三层?
你可能要问:为什么非得分三层?直接写不行吗?
嗯,我以前也这么干过。早期做的一个短视频项目,所有代码都堆在Activity里。录制、编辑、上传的逻辑混在一起,一个方法上千行。后来要加一个新功能——分段录制,改得我头皮发麻。那次之后,我老老实实重构了架构。
三层架构的好处,说白了就是:各管各的,出了问题好找人。
- UI层:只负责展示和接收用户操作。按钮点击、滑动、长按这些,统统交给ViewModel处理。UI层不直接调用CameraX或MediaCodec。
- 业务逻辑层:这是大脑。录制开始/暂停、分段管理、滤镜切换、草稿保存,都在这里决策。它通过UseCase调用底层服务,再把结果通过LiveData抛给UI层。
- 数据层:干活的。CameraX负责采集画面,MediaCodec负责编码,FFmpeg负责合成,Retrofit负责上传。它们只提供接口,不关心业务逻辑。
核心原则:每一层只依赖它的下一层,绝不跨层调用。UI层不能直接操作CameraX,数据层不能直接更新UI。
我记得有一次,产品经理说要把录制按钮从圆形改成方形。如果是以前,我得在Activity里翻半天找按钮逻辑。现在?只需要改UI层的Compose代码,ViewModel和数据层完全不动。十分钟搞定。
30.2 UI交互实现:让录制像呼吸一样自然
抖音的UI交互,核心就一个字:滑。上下滑切换视频,长按录制,松手暂停。这种交互方式已经被用户训练成了肌肉记忆。
我们来实现这个核心交互。我用的是Jetpack Compose,但思路同样适用于传统View系统。
// 录制按钮的核心状态
@Composable
fun RecordButton(
onStart: () -> Unit,
onStop: () -> Unit,
onProgress: (Float) -> Unit
) {
var isRecording by remember { mutableStateOf(false) }
var progress by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.size(72.dp)
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
isRecording = true
onStart()
// 启动一个协程来更新进度
coroutineScope.launch {
while (isRecording) {
delay(50)
progress += 0.005f
onProgress(progress)
if (progress >= 1f) {
isRecording = false
onStop()
break
}
}
}
},
onPress = {
awaitRelease()
if (isRecording) {
isRecording = false
onStop()
}
}
)
}
.clip(CircleShape)
.background(
if (isRecording) Color.Red.copy(alpha = 0.8f)
else Color.White
)
) {
// 外圈进度环
CircularProgressIndicator(
progress = progress,
modifier = Modifier.fillMaxSize(),
color = Color.Red,
strokeWidth = 4.dp
)
// 内圈录制图标
Icon(
imageVector = if (isRecording) Icons.Default.Stop else Icons.Default.FiberManualRecord,
contentDescription = "录制",
tint = if (isRecording) Color.White else Color.Red,
modifier = Modifier.align(Alignment.Center).size(32.dp)
)
}
}
这段代码看起来简单,但有几个坑我得提醒你。
我曾经踩过的坑:长按手势和点击手势冲突。如果你同时用了detectTapGestures和detectDragGestures,手指稍微一滑就会触发拖动,导致录制中断。我的解决方案是:录制按钮只响应长按,滑动切换交给父容器处理。各管各的,互不干扰。
另外,进度环的更新频率不要太高。50毫秒更新一次足够了,再快反而浪费CPU。你想想看,用户眼睛能感知的帧率也就60fps,16毫秒更新一次已经是极限了。
30.3 录制与编辑功能集成:分段录制 + 即时预览
抖音最让我佩服的一点是:录制和编辑的边界非常模糊。你录一段,马上就能看到效果,加滤镜、调速度、加贴纸,一气呵成。
要实现这种体验,关键在于分段管理。每一段视频都是一个独立的MP4文件,录制完成后,用FFmpeg把它们拼接起来。
// 分段视频的数据模型
data class VideoSegment(
val id: String = UUID.randomUUID().toString(),
val filePath: String, // 临时文件路径
val durationMs: Long, // 时长
val filterType: FilterType, // 滤镜类型
val speed: Float = 1.0f // 播放速度
)
// 录制管理器的核心逻辑
class RecordManager(private val context: Context) {
private val segments = mutableListOf<VideoSegment>()
private var currentSegment: VideoSegment? = null
private var isRecording = false
fun startNewSegment(filterType: FilterType) {
val fileName = "segment_${System.currentTimeMillis()}.mp4"
val filePath = File(context.cacheDir, fileName).absolutePath
currentSegment = VideoSegment(
filePath = filePath,
filterType = filterType
)
// 启动CameraX录制到该文件
startRecording(filePath, filterType)
isRecording = true
}
fun stopCurrentSegment() {
stopRecording()
currentSegment?.let {
it.durationMs = getRecordedDuration()
segments.add(it)
}
currentSegment = null
isRecording = false
}
fun mergeAllSegments(outputPath: String, callback: (Boolean) -> Unit) {
// 使用FFmpeg拼接所有分段
val cmd = buildString {
append("ffmpeg -y ")
segments.forEach { segment ->
append("-i ${segment.filePath} ")
}
append("-filter_complex \"")
segments.forEachIndexed { index, _ ->
append("[${index}:v:0][${index}:a:0]")
}
append("concat=n=${segments.size}:v=1:a=1[outv][outa]\" ")
append("-map \"[outv]\" -map \"[outa]\" $outputPath")
}
FFmpegExecutor().execute(cmd, callback)
}
}
这里有个细节:每个分段可以独立设置滤镜。录制时实时叠加滤镜,而不是录完再处理。这样用户体验更流畅,但代价是CPU压力大。我在项目中遇到过,低端手机上同时开滤镜和录制,掉帧严重。后来做了个策略:低端机默认关闭实时滤镜,录制完成后统一渲染。
我的建议:在Application启动时检测设备性能。如果CPU核心数少于4或内存小于3GB,就启用「轻量模式」。这个判断只需要做一次,但能避免很多线上崩溃。
30.4 发布与分享:从本地到云端
视频编辑好了,最后一步是发布。这一步看似简单,其实坑最多。
首先是上传策略。一个1080p、30秒的视频,压缩后也有10-20MB。如果用户网络不好,上传到一半断了怎么办?
我的做法是:分片上传 + 断点续传。把视频切成1MB一片,逐片上传。服务器收到所有分片后合并。如果某一片上传失败,只重传那一片,不用从头再来。
// 分片上传的核心逻辑
class UploadManager(private val api: UploadApi) {
fun uploadVideo(filePath: String, progressCallback: (Float) -> Unit) {
val file = File(filePath)
val chunkSize = 1024 * 1024 // 1MB
val totalChunks = (file.length() + chunkSize - 1) / chunkSize
val uploadId = UUID.randomUUID().toString()
GlobalScope.launch(Dispatchers.IO) {
for (i in 0 until totalChunks) {
val start = i * chunkSize
val end = minOf(start + chunkSize, file.length())
val chunkData = file.readBytes().copyOfRange(start.toInt(), end.toInt())
// 上传分片
val result = api.uploadChunk(
uploadId = uploadId,
chunkIndex = i,
totalChunks = totalChunks,
data = chunkData
)
if (!result.isSuccess) {
// 重试3次
retry(3) { api.uploadChunk(uploadId, i, totalChunks, chunkData) }
}
// 更新进度
progressCallback((i + 1).toFloat() / totalChunks)
}
// 通知服务器合并
api.completeUpload(uploadId, file.name)
}
}
}
其次是分享功能。抖音的分享不只是发到自己的平台,还要支持微信、微博、QQ。Android的ShareSheet虽然能用,但体验很差。我建议用目标式分享:检测用户手机上安装了哪些App,把最常用的几个排在前面。
分享的隐藏细节:分享到微信时,视频不能超过10MB,否则会被压缩成渣。我的策略是:检测目标App是微信,就自动降低码率,生成一个5MB左右的版本。分享到抖音自家平台,则上传原画质。
最后是草稿箱。用户录了一半,突然有电话进来,或者App被杀了。如果没保存,用户会疯的。我的做法是:每录完一个分段,立即写入Room数据库。App重启时,检测是否有未完成的草稿,提示用户继续编辑。
// 草稿箱数据表
@Entity(tableName = "drafts")
data class DraftEntity(
@PrimaryKey val id: String,
val segmentsJson: String, // 分段列表的JSON
val coverPath: String, // 封面图路径
val createdAt: Long,
val updatedAt: Long
)
嗯,到这里,整个仿抖音相机的核心功能就串起来了。从架构设计到UI交互,从录制编辑到发布分享,每一步都有它的设计逻辑和坑点。你把这些代码跑起来,再对照抖音的体验,会发现——原来那些看似神奇的功能,背后都是扎实的基础功。
做这个项目,我最大的感受是:不要追求一次完美。先让核心流程跑通,再慢慢优化细节。用户不会因为你按钮动画不够流畅就卸载App,但会因为录制到一半闪退而骂娘。
好了,动手试试吧。把前面29章的知识点,在这个项目里用起来。遇到问题别慌,翻翻前面的章节,或者自己动手调试一下。相信我,当你看到自己做的App能录制、编辑、发布一条龙时,那种成就感,比看任何教程都爽。