30. 综合实战:仿抖音相机:项目架构设计、UI交互实现、录制与编辑功能集成、发布与分享。

终于到了最后一章。说实话,写到这里我自己也有点感慨。前面29章我们一步步把摄像头采集、编码、美颜、滤镜、音频处理这些零散的知识点都啃下来了。现在,是时候把它们串起来,做一个真正能跑、能录、能编辑、能分享的App了。

目标很明确:仿抖音相机。但别误会,我们不是要抄抖音,而是要理解它背后的架构思路。抖音那种流畅的录制体验、即时的编辑反馈、一键发布的流程,背后其实是一套非常成熟的设计模式。

我个人习惯,做这种综合项目前,先画一张架构图。把脑子里的想法倒出来,比直接写代码要靠谱得多。

仿抖音相机 - 项目架构图 UI 层 (View Layer) CameraPreview | RecordButton | ProgressBar | EditPanel | PublishDialog 基于 Jetpack Compose / Fragment,响应式状态驱动 业务逻辑层 (ViewModel / UseCase) CameraViewModel | RecordUseCase | EditUseCase | PublishUseCase 管理录制状态、分段视频、滤镜参数、发布流程 通过 LiveData / StateFlow 与 UI 层通信 数据 / 服务层 (Data Layer) CameraX | MediaCodec | AudioRecord | FFmpeg | Retrofit 视频采集、编码、音频录制、视频合成、网络上传 Room (本地草稿) | SharedPreferences (配置)

这张图我画了三层: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)
        )
    }
}

这段代码看起来简单,但有几个坑我得提醒你。

我曾经踩过的坑:长按手势和点击手势冲突。如果你同时用了detectTapGesturesdetectDragGestures,手指稍微一滑就会触发拖动,导致录制中断。我的解决方案是:录制按钮只响应长按,滑动切换交给父容器处理。各管各的,互不干扰。

另外,进度环的更新频率不要太高。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能录制、编辑、发布一条龙时,那种成就感,比看任何教程都爽。

公众号:蓝海资料掘金营,微信deep3321