哔哩哔哩Android视频编辑页的架构升级

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-

作者丨阿建

相关推荐
奕辰杰2 小时前
关于npm前端项目编译时栈溢出 Maximum call stack size exceeded的处理方案
前端·npm·node.js
JiaLin_Denny4 小时前
如何在NPM上发布自己的React组件(包)
前端·react.js·npm·npm包·npm发布组件·npm发布包
路光.5 小时前
触发事件,按钮loading状态,封装hooks
前端·typescript·vue3hooks
我爱996!5 小时前
SpringMVC——响应
java·服务器·前端
咔咔一顿操作6 小时前
Vue 3 入门教程7 - 状态管理工具 Pinia
前端·javascript·vue.js·vue3
kk爱闹6 小时前
用el-table实现的可编辑的动态表格组件
前端·vue.js
漂流瓶jz7 小时前
JavaScript语法树简介:AST/CST/词法/语法分析/ESTree/生成工具
前端·javascript·编译原理
换日线°7 小时前
css 不错的按钮动画
前端·css·微信小程序
风象南7 小时前
前端渲染三国杀:SSR、SPA、SSG
前端
90后的晨仔7 小时前
表单输入绑定详解:Vue 中的 v-model 实践指南
前端·vue.js