1.背景介绍
在数字内容井喷的时代,移动端已成为视频创作的重要阵地,而视频编辑页作为创作工具的核心场景,不仅为创作者提供了丰富的表达手段和创意平台,更是提升视频制作的效率。通过直观的操作界面和丰富的功能集成,用户可以轻松地将素材、音频、特效及文字等进行融合,创造出独具风格、彰显个性的作品。
然而,视频编辑页的页面设计和代码实现也充满了复杂性和挑战。在单一页面中集成了视频、音频、文字、贴纸、特效等多种功能。横向上,数十个模块互相交织与影响;纵向上,每个模块又提供了丰富的功能和精细化的操作。如此的业务复杂度,对页面架构以及功能代码的设计提出了更高的要求。

2. 明确需求
视频编辑页的初版上线后,一直未进行大型升级,旧版本存在较多不足。如轨道过多,信息冗余,用户理解成本高,主轨道设计落后,视频轨道操作不方便等问题。
产品团队希望进行一次业务升级,以轻量化的视频编辑为核心思路,从界面布局、用户交互、已有功能优化、新增必要功能等方面对编辑页进行改版。
3. 需求分析
在工程实施之前,我们需要对本次需求进行全面分析,从而指导代码设计。
3.1 业务升级要做些什么的?
本次业务升级的重点,可以归纳为以下四个方面。
界面布局: 重新定义编辑页的交互方式,划分操作区域,升级主页面和操作面板的视觉设计。
用户交互: 全新设计视频轨道、素材多轨道、工具栏、快速编辑等核心控件的交互方式。
功能优化: 对剪辑、文字、贴纸、转场、音乐等已有业务功能进行升级和多轨道改造。
新增功能: 增加Redo/Undo、文字快速编辑、画面编辑、全屏预览等实用功能。

3.2 代码的现状是怎么样的?
那当前的代码支持进行如此大型的业务升级吗?我们从以下几个方面进行了分析:
页面架构: 旧界面缺乏架构设计,以MVC为主,夹杂MVP的使用。绝大部分代码存在于Activity以及Fragment中,存在超过5000行代码的超级类,逻辑杂糅,维护困难。
业务模块: 功能分布以主Actvitiy(主界面)+ Fragment(模块功能)的形式实现。通过互相持有对象的方式进行Activity与Fragment的通信,耦合严重。
灰度要求: 如果在原代码上修改,实现新旧界面的灰度势必是场灾难。如果通过copy代码的方式进行,没有解决旧代码的问题,又新增很多代码,只会让页面的可维护性再次降低。

3.3 需要考虑的关键问题
基于业务诉求和代码现状,我们决定对编辑页进行重构,从架构设计到关键功能点进行全面升级。那么接下来需要思考如何进行界面重构及部分功能点的设计?
页面的架构选择
移动应用开发领域的常见的页面架构,包括MVC、MVP、MVVM、MVI、VPIER等多种类型。综合考虑各种架构的优缺点和实现成本,结合业务特点,我们最终选择MVVM+UDF的页面架构。
新架构如何兼容旧功能?
业务升级会以多个需求并行开发,逐步上线的方式完成,并不是一次性完成所有升级,所以需要考虑如何兼容旧的功能面板。
怎样设计Redo/Undo功能?
Redo/Undo作为一个必要的、使用广泛的功能,代码设计时需要考虑其低侵入性、高可用性和高性能。
如何设计复杂的轨道控件?
作为编辑页的控件中可操作性强,逻辑复杂性高的代表,该控件的代码设计也非常重要。
4. 具体方案
4.1 页面架构设计
4.1.1 设计原则
在确定采用MVVM+UDF的架构后,我们确定了以下的页面架构设计原则:
(1)整体采用MVVM进行架构设计。
(2)遵循数据模型(UiState)驱动界面的原则。
(3)基本遵循单一可信数据源的原则。(SSOT:Single Source of Truth)
(4)遵循单向数据流原则。(UDF:Unidirectional Data Flow)
(5)对业务进行模块拆分,提高模块内的代码内聚,降低模块间的代码耦合。
(6)使用依赖注入,提高代码复用性,解决对象依赖问题。
如下图为简化的架构设计图:

4.1.2 分层设计
架构设计在纵向上进行了分层设计,整体拆分为界面层 + 业务层 + 数据层。
UI Layer: 界面层,分为UI元素(Activity/Fragment)以及状态容器ViewModel,通过UiState(界面状态)来控制界面元素的变化。
UiState/UiStateHolder:UiState作为界面的唯一可信数据源,以不可变类型公开数据。具有聚合数据,保护数据,易于跟踪对数据的更改等多种优势。UiStateHolder用于聚合同一业务模块的UiState。
以播放按钮的界面状态为例,其UiState定义如下:
kotlin
data class PlayBtnUiState(
val isShowPlayView: Boolean,//播放按钮的显示或隐藏状态
val isPlaying: Boolean,//播放按钮的播放或暂停状态
val onPlayViewClick: (() -> Unit)?//播放按钮的点击事件回调
)
LiveData/StateFlow:作为数据流的中介,界面层通过监听UiState的变化,控制界面状态变化。
scss
//在UiStateHolder中定义状态
val playBtnUiState = MutableLiveData<PlayBtnUiState>()
//在Activity(UiComponent)中监听变化
stateHolder.playBtnUiState.observe(owner) { uiState ->
//控制控件状态更新
updatePlayViewUiState(uiState)
}
Domain Layer: 网域层,在视频编辑页这样复杂度的单页面架构设计中,该层必不可少,其封装复杂的业务逻辑,使用大量的UseCase来用于处理复杂的业务逻辑并且支持业务代码的可重用性。
Data Layer: 数据层,由多个业务仓库(Repository)以及数据源(Data Sources)组成。在实际设计中,为了尽可能的兼容已有的数据获取方式以及业务特点,该层的具体实施中并不完全照本宣科,而是基于推荐的架构设计进行了一定程度的改造。
4.1.3 模块拆分
在横向上按照业务模块在界面层、网域层进行了模块拆分。

UiComponent: 按照界面特点拆分为顶部栏模块、预览模块、中部状态模块、主操作模块、业务面板模块等。
UiStateHolder: 对于不同的业务模块,每个业务模块会使用一个或多个UiStateHolder来持有LiveData/StateFlow。
UseCase: 根据作用域的不同,UseCase可以分为以下三个级别:
界面模块级:其模块划分遵循界面层的定义,和Ui的模块划分保持一致。
业务子模块级:按照剪辑、字幕、贴纸、音乐等不同的编辑业务进行模块拆分。
业务子功能级:在业务功能模块中,将不同的子功能进行聚合,众多子功能组成该模块的完整功能。

4.1.4 单向数据流(UDF)
在架构的事件设计中,遵循状态(UiState)从数据层流向界面,事件(Event)从界面流向数据层的设计原则。
UDF有助于实现数据一致性、可测试性、可维护性。如Google官方数据流转图所示:

4.1.5 依赖注入(DI)的使用
当业务功能进行细粒度的拆分之后,如何进行优雅的进行对象依赖、代码复用和对象生命周期管理就成了问题。我们结合业务现状选择了依赖注入框架Hilt来解决这些问题。
以添加贴纸的功能点,来说明如何使用依赖注入的。
less
@HiltViewModel
class EditorStickerListViewModel @Inject (
private val projectRepository: ProjectRepository,
private val streamingRepository: StreamingRepository,
private val stickerAddUseCase: EditorStickerAddUseCase,
private val materialTrackUseCase: TrackCommonUseCase
)
EditorStickerListViewModel: 该ViewModel是贴纸列表面板所对应的VM,其通过@HiltViewModel进行构建,生命周期与Fragment对象保持一致。在它构造函数中,我们注入了多个对象,其作用如下:
ProjectRepository: 项目数据管理。其他内部持有唯一的项目数据EditorProject。
StreamingRepository: 渲染引擎管理。其内部通过与EditorEngine交互,实现对渲染引擎的数据设置和状态控制。
TrackCommonUseCase: 负责轨道控件的显示状态控制,其内部注入UiStateHolder对象,通过修改UiState来更新该控件的显示状态。
简化的添加贴纸素材的代码如下:
kotlin
fun addStickerMaterials(dataList:List<MaterialItem>){
//1. 素材列表转换为项目的贴纸节点列表
val stickerClipList = createStickerBClipList(dataList)
//2. 将素材节点列表添加进项目数据中
projectRepository.addStickerClips(clipList)
//3. 将素材节点列表添加到渲染引擎中
streamingRepository.insertStickerClipList(insertTime,stickerClipList)
//4. 刷新视频轨道控件
materialTrackUseCase.refreshMultiMaterialTrack()
//5. 刷新渲染引擎
streamingRepository.refreshCurrentTime()
}
依赖注入的对象的生命周期管理
使用Hilt框架进行依赖注入时,需要合理的选择每个对象应该有的生命周期。基于不同的生命周期合理选择@ActivityScoped、@ViewModelScoped、@FragmentScoped等注解,或自定义生命周期。
4.1.6 架构设计的总结
我们基本遵循Google的架构设计指南,在上述的架构设计原则下进行整体的架构设计和代码实践。
在MVVM + UDF的架构中,MVVM进行视图和逻辑的解耦,而UDF决定了状态的管理范式。
该架构解决了传统MVVM(双向绑定)的缺陷,让程序的关注点分离清晰,避免了数据不一致和状态管理分散的问题,使得代码易于调试和测试。
其也是现代UI框架(Compose,SwiftUI等)理念的自然延伸和最佳实践,在如今跨平台的浪潮下,无疑也是构建跨端的、健壮的、可维护和可测试应用的强大范式,随着Compose Multiplaform的成熟,该架构在KMP中能进一步实现UI与逻辑的跨平台复用。
4.2 部分功能点的设计和实现
4.2.1 如何兼容旧业务功能
核心问题: 在新编辑器页与旧业务面板共存的过渡阶段,需要确保新页面完整支持旧面板的显示和其依赖的界面能力,如主界面UI控制、引擎状态监听与操作、数据流交互等。
技术方案: 将旧面板对主界面的强依赖改造为接口化设计。
(1)抽象关键能力:将原Activity中被旧面板调用的100余函数,抽象为接口。
(2)代理层实现:旧界面Activity直接实现接口,保持原有逻辑。新界面通过代理机制,将函数路由到新架构的对应功能模块。
(3)灰度控制:通过实验结果动态切换接口的具体实现方。
后续优化: 过渡期完整保留代理层,确保新旧界面无缝协作。旧业务面板改造完成后,直接移除该部分代码。

4.2.2 底部功能面板组件
编辑页中,有数十个大小不一,功能迥异的功能面板通过Fragment的方式嵌入在界面中,需要对功能面板进行统一的设计和管理。

面板的基类(EditorBaseFragment): 新基类仅拥有少量与业务无关,用于定义面板通用规范的函数。
面板的管理: 统一管理Fragment的添加和移除、进出动画、面板栈等。
面板模态设计: 定义三种不同高度和编辑模式的面板模态,从Fragment的容器高度就确定了面板的高度,从而规范了面板的设计。
c
enum class FragmentContainerModal {
/**
* 半模态-露出部分轨道
* */
MODAL_HALF,
/**
* 全模态-遮挡整个操作区域
* */
MODAL_ALL,
/**
* 全屏模态-容器为全屏
* */
MODAL_FULL_SCREEN
}
4.2.3 视频轨道控件的设计
视频轨道控件作为编辑页的复杂控件之一,其高可操作性和业务复杂性对控件的设计提出了较高的要求。该控件的代码结构设计如下:

控件状态管理: 统一定义控件的状态,用来管理控件在不同业务场景下的UI状态及操作状态变化。
绘制体系: 整个控件的绘制体系采用自定义Canvas绘制 + 少量View的方式设计。其中绘制顺序及View的添加顺序决定了元素的显示层级。
事件体系: 如果是对装饰性View的操作,则直接采用对View设置监听事件的方式实现,对于自定义绘制的视频节点、转场等元素,则自定义事件的分发流程,按照元素的位置+层级来确定事件的分发优先级。
动画体系: 从节点、装饰元素、控件等多个层面,和点击、位移、滚动等多种事件类型,提供自定义动画支持。
性能优化:
-
绘制优化:采用只绘制处于屏幕内的内容的方式来提升控件的绘制性能。
-
抽帧优化:设计了抽帧组件以及帧图片的两级缓存框架,从而提高取帧的性能。
4.2.4 Redo/Undo功能的设计
视频编辑页中,用户会频繁的对视频、音频、特效等进行各种不同的编辑操作,而允许对用户的操作进行撤销和重做就成了非常必要的事情,该功能提高了视频编辑操作的容错性。
通常Redo/Undo功能有两种不同的设计思路:备忘录模式和命令行模式。在平衡性能、实施成本和实际效果后,我们选择了备忘录模式进行功能的设计和实施。

备份数据: 备份数据为视频编辑项目数据对象,其是对当前视频编辑项目的详细描述,是渲染引擎和核心界面状态的数据来源,通过备份和重置项目数据,即可使整个编辑页回到某个指定的状态。
二级缓存机制: 设置内存缓存和磁盘缓存两级存储结构。内存缓存作为一级缓存,提供高速访问的能力。磁盘缓存作为二级缓存,提供大容量存储。
滑动窗口与链式结构: 在内存缓存中设置一个滑动窗口,备份数据采用双向链表结构存储,指针始终指向第三个备份。
问题和更优的方案:
该功能目前的设计方案是时间方面妥协的产物,并不是视频编辑页的Redo/Undo功能的最佳方案,其存在全量刷UI、内存占用等方面的问题。
在编辑页采用MVVM+UDF的架构设计现状下,该功能天然适配命令行模式,用户的行为或意图(Action/Intent)即视频编辑的命令(Command),其经过命令执行后,带来界面状态(UiState)和渲染引擎的变化,与UDF的理念非常吻合,无疑是更加合理的、更符合页面架构的设计方案。
5. 总结
视频编辑页作为业务复杂度较高的单页面,经过多次需求迭代,最终完成所有业务升级,前后历时半年,代码修改量9w+。本次业务升级的顺利完成是产品、设计、测试和研发等多个团队紧密协作、齐心合作的结果。
经过本次编辑器改版,个人对于软件开发和架构设计也有一些新的感悟。
什么时候该进行代码重构或架构升级?
局部重构应该是一件持续进行的事情,最好的时间就是现在。而颠覆式的架构升级应该全面评估业务影响和ROI,在没有专门的时间进行架构升级时,跟随重大的需求变更同步进行架构演进,会更容易落地。
软件架构设计的重点是什么?
软件架构的设计并不是越复杂、越精细就会越好。过于复杂的设计只会让代码维护成本增加。执着于细节只会陷入无穷无尽的业务逻辑中。抓住主要问题、结合业务特点、确立架构设计原则、团队达成共识是更为重要的事情。
-End-
作者丨阿建