由deepai.org生成
在这篇文章中,我将向你讲述我们团队如何使用MVI架构,完全用Kotlin创建了一个完整的跨平台游戏引擎的故事。你还将了解到,在开发该引擎时,我如何应对和实现客户的一些疯狂要求。那么我们直接开始吧!
所以我正在开发一个叫Overplay的应用程序。它类似于TikTok,但你看到的视频实际上是可以边滑动边玩的游戏。有一天,我正在画另一个按钮,这时客户来找我讨论应用的性能和游戏的开始与结束时的体验。简单来说,我们多年来一直在处理的问题是旧的游戏引擎。
它在Android上仍然使用XML,并包含7千行旧代码,其中大部分已经失效但仍被执行,拖累了性能。体验非常卡顿,加载一个游戏需要20秒,这对我们来说是家常便饭,而且经常卡顿。我们也经常遇到与并发和状态管理有关的严重崩溃,因为引擎的数十个不同部分同时发送事件并更新游戏状态……团队对此束手无策——我们当前的简单MVVM架构已经完全支撑不住了。仅ViewModel就有2000行代码,任何改动都可能引发其他问题。
所以客户说——是时候让游戏引擎再次变得牛逼了。但他的新需求简直疯狂。
- 游戏引擎必须直接整合到游戏流中,以便用户在完成游戏后可以继续滚动。这意味着它必须嵌入另一个页面中,并携带所有逻辑。
- 游戏引擎必须在不到2秒的时间内启动游戏。这意味着所有内容需要在后台并行处理,即使用户在滚动时也是如此。
- 如果用户重玩或重新开始游戏,加载必须是瞬时的。因此,我们必须保持引擎运行,并动态管理资源。
- 用户的每一个动作都必须被分析覆盖,以便我们能够不断改进它。
- 游戏引擎必须支持各种视频,包括本地视频,以便有人想制作并游玩自己的游戏。
- 由于用户像在 TikTok 上那样滚动视频,我们需要高效地释放并重用媒体编解码器和视频播放器,以便流畅地在播放游戏视频和其他项目之间切换。
- 所有错误都必须始终被处理、报告和恢复,以确保我们不再因崩溃而破坏用户体验,让用户感到不满。
说实话我还以为我要被炒了
“这个疯狂的逻辑无法实现”——我想。应用程序的一半必须很容易集成,状态必须始终一致,同时有数百个状态更新进行中:设备传感器、我们的图形引擎、视频播放器等。所有内容必须重复使用且并行加载。为了让团队有足够的空间进行未来的引擎改动而不自废武功,代码量也必须保持较小。
但我还是得去做,根本没有逃避的可能。当然,这个任务也不是我一个人能完成的。团队的努力非常值得赞赏:
- 有一名成员使我们的图形引擎与Compose兼容,因为我们必须使用Compose。
- 另一位开发人员也做了一个游戏循环模块,该模块负责发送事件并协调图形引擎。
所以我负责游戏加载和整体集成。我想这些需求其实不是在讲功能,而是在讲架构。我的任务就是实现一个能满足所有这些需求的架构。说起来容易做起来难啊……
如下所示,这是我的最终设计的简化图:
不好意思,我的UML有点生疏了,所以这里可能有些不合规。
在实现新架构前,需要理解的一个重要点是,我从Ktor及其令人赞叹的“插件”系统中得到灵感。这些插件形成一个责任链来拦截任何传入和传出的事件。为什么不将这个用到任何业务逻辑中呢?我认为这是一种新的应用架构方法,因为我们过去只会在后端使用CQRS或在网络代码中采用这种方法。
幸好,我们使用的架构框架——FlowMVI——已经实现了这个功能,所以我不需要写任何新的代码,只需要巧妙地使用插件就可以了。但是这个框架本来是为UI设计的,而不是游戏引擎!如果我不想被炒鱿鱼,我得对它做一些改动。
所以在这两周里,我花了一些时间搭建了支撑性的基础设施:
- 我增加了很多新的插件,这将允许我将任何代码注入游戏引擎生命周期中的任何环节。稍后我们会详细讨论这些插件。
- 我进行了数百次的基准测试比较,与最快的解决方案进行比较,以确保我们获得最佳性能。我不断优化代码,直到库的性能优化到在35多个框架的基准测试中位列前五,并且与直接使用简单的Channel(来自协程)的效果一样好。
- 我实现了一个新的系统来监控插件调用链,这使我能透明地监控任何业务逻辑的执行情况,我很有创意地将其命名为“装饰模式”。
我也给自己设了个规矩——任何逻辑都必须独立于引擎代码,能够单独移除和修改。我的目标是——我要确保引擎的代码长度不超过400行。
这感觉就像自己成了电影里的特工,装备齐全。我准备好了要碾碎这一切。
咱们走吧。
开始 — 合同首先,我们给我们的引擎定义一个简单的MVI状态、意图和副作用的家庭。我使用了FlowMVI的IDE插件,在新文件中输入fmvim
后,我得到了如下内容。(注:fmvim
为特定命令或代码)
internal sealed interface GameEngineState : MVIState {
data object Stopped : GameEngineState
data class Error(val e: Exception?) : GameEngineState
data class Loading(val progress: Float = 0f): GameEngineState
data class Running(
override val game: GameInstance,
override val player: MediaPlayer,
override val isBuffering: Boolean = false,
) : GameEngineState
}
internal sealed interface GameEngineIntent : MVIIntent {
// 这里后来添加了许多...
}
internal sealed interface GameEngineAction : MVIAction {
data object GoBack : GameEngineAction
}
我还添加了一个 Stopped
状态(因为我们引擎即使在不播放时也存在),并且在加载过程中加入了进度值。
我从创建一个名为Container的单例开始,它将托管依赖。我们需要确保它是单例,并在需要时启动和停止所有操作,以支持游戏的即时回放和数据缓存。我们将尝试在其中安装一系列插件来管理我们的逻辑。为了创建它,我在一个新文件中输入了fmvic
,并添加了一些配置。
internal class GameEngineContainer(
private val appScope: ApplicationScope,
userRepo: UserRepository,
configuration: StoreConfiguration,
pool: PlayerPool,
// ...还有一些其他的东西
) : Container<GameEngineState, GameEngineIntent, GameEngineAction>, GameLauncher {
override val store by lazyStore(GameEngineState.Stopped) {
configure(configuration, "GameEngine") // (1)
configure {
stateStrategy = Atomic(reentrant = false) // (2)
allowIdleSubscriptions = true
parallelIntents = true // (3)
}
}
}
这样,我们就能轻松地在这里注入依赖项。这个“Store”对象将托管我们的GameState
状态,响应GameIntents
,并向UI发送事件(GameActions
)消息。
- 我通过DI透明地向商店中注入了一些内容,稍后再详细说明。
- 在我的基准测试中,我发现可重入的状态事务(在我的上一篇文章中讨论过)导致了严重的性能问题。它们比非可重入的事务慢了15倍!虽然时间仍然以微秒为单位,,对于简单的UI来说使用它们还是有意义的,但我们必须榨干每一滴CPU性能以供引擎使用。我在最新更新中增加了对这些事务的支持,这将每事件的时间缩短到了纳秒级!
- 为了使游戏引擎保持高速运行,所有操作都需要并行处理,因此我启用了并行处理。但是如果不同步状态访问,我们就会像之前那样遇到竞态条件!通过启用该标志并保持原子性的状态事务,我实现了速度和安全性的完美结合!
我们已经做到这一步了:
- 快速
- 线程安全
- 按需加载资源
- 分析和崩溃记录。
“且慢”,你也许会问,“代码片段中居然没有一行分析代码!”,我会这样回答——真正的妙处就在这个注入的 configuration
参数里。
它会悄悄地安装一堆插件。我们可以使用插件的概念为任何容器添加任意逻辑,那么为什么不使用这些带有依赖注入功能的插件呢?该功能安装了一个错误处理插件,可以捕获并发送异常给分析系统,而不影响引擎其他部分的代码,同时跟踪用户操作(意图)以及访问和离开游戏引擎屏幕的事件。对于我们来说,让庞大的游戏引擎被分析相关的垃圾代码污染是不可接受的,因为我们之前在MVVM中遇到过这个问题——所有东西都不断堆积,直到变得难以维护。这种情况不会再发生了。
启动和关闭引擎:好的,我们已经延迟加载了容器。现在我们该怎么清理和管理这些资源呢?
关于 FlowMVI 的一个特点是,据我所知,它是唯一一个允许你根据需要 暂停 和 重启 业务逻辑组件(Store
)的框架。每个 Store 都有一个 StoreLifecycle
,可以让你使用 协程作用域
来控制和观察 Store。如果协程作用域被取消,Store 也会被取消,但也可以单独暂停 Store,确保始终遵循我们的父子层级关系。
我的同事一开始对这个功能很怀疑,我也一度认为它没什么用。但这次它真的救了我,免于被炒:我们可以用全局应用作用域来运行逻辑,并且在不需要的时候关闭引擎,这样就不消耗资源了!
为了实现这一点,我们将让容器实现一个名为 GameLauncher
的接口,它将帮助我们管理生命周期。
override suspend fun awaitShutdown() = store.awaitUntilClosed()
override fun shutdown() = store.close()
override suspend fun start(params: GameParameters) {
val old = this.parameters.getAndUpdate { params }
when {
!store.isActive -> store.start(appScope).awaitStartup() // 从新开始
old == params -> store.intent(重播游戏) // 重用引擎
else -> { // 重新启动不兼容的版本
store.closeAndWait()
store.start(appScope).awaitStartup()
}
}
}
其他模块的代码将只通过接口在不需要游戏继续运行时(比如用户滚动走开了,离开了应用等)停掉引擎,并在每次客户端希望开始游戏时就调用 start
。但如果商店没有一种方式在关闭时做些事情,那么这个功能对我们来说就没什么实际用处了。接下来我们来说说资源管理。
游戏开始时,我们有很多东西要并行启动:
- 远程配置特性标志
- 游戏资源例如纹理需要下载并缓存
- 游戏配置及 JSON 数据
- 媒体编解码器的初始化
- 视频文件的缓冲和缓存
- 等更多功能……
在这里几乎所有的内容都不能简单地被垃圾回收。我们需要关闭文件句柄,卸载解码器插件,释放由本地代码占用的资源,并将视频播放器归还到池中以便重复利用,因为创建播放器是一个非常耗资源的过程。
有些东西实际上还和其他东西挂钩,比如视频文件和它来源的游戏配置之间的关系。我们如何实现这一点?
首先,我做了一个插件,这个插件会利用上面提到的回调,发动机启动时创建一个值,发动机停止时再清理这个值(这个简化代码):
public fun <T> cached(
init: suspend PipelineContext.() -> T,
): CachedValue<T> = CachedValue(init)
fun <T> cachePlugin(
value: CachedValue<T>,
) = plugin {
onStart { value.init() }
onStop { value.clear() }
}
CachedValue
就如同 lazy
,但具有线程安全的时机控制来清除和初始化值。在我们的场景中,当存储启动时会调用 init
,而存储停止时会清除引用。超级简单。
但那个插件仍然存在问题,因为它会暂停整个商店的运作,直到初始化完成,这意味着我们的加载会变成顺序而不是并行的。要解决这个问题,我们可以简单地通过使用 Deferred
并在单独的协程里运行初始化。
inline fun <T> 异步缓存(
context: 协程上下文 = 空协程上下文,
start: 协程启动方式 = 协程启动方式.未调度,
crossinline init: 悬挂上下文.() -> T,
): 缓存值<异步<T>> = 缓存 { 异步(context, start) { init() } }
然后我们就用 asyncCached
替代原来的 cache
插件。在其之上添加一些 DSL 特性,就可以得到如下的游戏加载逻辑:
override val store by lazyStore(GameEngineState.Stopped) {
configure { /* ... */ }
val gameClock by cache {
GameClock(coroutineScope = this) // (1)
}
val player by cache {
playerPool.borrow(requireParameters.playerType)
}
val remoteConfig by asyncCache {
remoteConfigRepo.updateAndGet()
}
val graphicsEngine by asyncCache {
GraphicsEngine(GraphicsRemoteConfig(from = remoteConfig())) // (2)
}
val gameData by asyncCache {
gameRepository.getGameData(requireParameters.gameId)
}
val game by asyncCache {
GameLoop(
graphics = graphicsEngine(),
remoteConfig = remoteConfig(),
clock = gameClock,
data = gameData(),
params = requireParameters,
).let { GameInstance(it) }
}
// ... 等等 ...
asyncInit { // (3)
updateState { 加载中 }
player.loadVideo(gameData().videoUrl)
updateState {
GameEngineState.运行中(
game = game(),
player = player,
)
}
clock.start()
}
deinit { // (4)
graphicsEngine.release()
player.stop()
playerPool.returnPlayer(player)
}
}
- 我们的游戏时钟运行一个事件循环,并将游戏时间与视频时间同步。不幸的是,它需要一个仅在游戏期间处于活动状态的协程作用域来运行循环。幸运的是,我们已经有一个了!
PipelineContext
是Store
执行的上下文,它提供了插件并实现了CoroutineScope
。我们可以在我们的cache
插件中使用它,并启动游戏时钟,当关闭引擎时它会自动停止。 - 可以看到我们使用了大量的
asyncCache
来并行加载,并且借助图形引擎,我们还可以在其中依赖远程配置(例如,在实际情况中,它可能依赖于很多其他因素)。这极大地简化了我们的逻辑,因为现在组件之间的依赖关系是隐式的,而只想使用图形引擎的请求方无需管理它的依赖项!操作符调用(括号)是Deferred.await()
的简写,带来额外的甜味。 - 我们还使用了
asyncInit
,这实际上是在当前游戏引擎的游戏作用域中启动了一个后台任务来加载游戏。在该任务中,我们进行最后的准备,等待所有依赖项,并启动游戏时钟。 - 我们使用了内置的
deinit
插件,将所有清理逻辑放在一个回调中,该回调在游戏引擎停止(并取消其作用域)时立即调用。它将在我们的缓存值被清理之前运行(因为它被安装在之后),但在任务取消后,这样我们就可以做我们想做的事情,而cache
插件随后会自动回收资源,无需担心内存泄漏。
总的来说,这50行代码取代了我们旧游戏引擎实现的1500多行代码!当我意识到这些模式在业务逻辑上的强大表现时,我惊得下巴都掉了。
但我们还是缺少一样东西啊。
处理错误游戏中引擎可能会出很多问题。
- 某个游戏作者忘记给动画加帧了,
- 有人在玩游戏时断线了,
- 由于平台错误,着色器没能正常渲染,等等
一般情况下,只有像 [ApiResult](https://github.com/respawn-app/ApiResult)
这样的包装器或某种 try-catch 主要处理 API 调用中的主要错误。但想象一下,如果把游戏引擎的每一行代码都用 try-catch 包裹……那会有一堆冗长的 try-catch-finally 代码!
嗯,你大概知道接下来会发生什么。既然我们现在能拦截任何事件,我们来做个错误处理插件吧!我叫它recover,现在的代码看起来像这样:
override val store by lazyStore(GameEngineState.停止状态) {
configure { /* ... */ }
val player by cache { /* ... */ }
// ...
recover { e ->
if (config.debuggable) updateState { // (1) 注释
GameEngineState.错误状态(e)
} else when(e) { // (2) 注释
is StoreTimeoutException, is GLException -> 忽略 // 仅报告给分析
is MediaPlaybackException -> player.retry()
is AssetCorruptedException -> assetManager.refreshAssetsSync()
is BufferingTimeoutException -> action(ShowSlowInternetMessage)
// ... 更多情况 ...
else -> shutdown() // (3) 注释
}
null // 忽略异常
}
}
-
如果我们的商店配置了调试功能(
config
在商店插件中可用),我们可以显示一个全屏的错误覆盖,显示堆栈跟踪信息,让我们的 QA 团队在错误到达生产环境之前可以轻松地向开发人员报告错误。快速失败机制 原则的应用。 -
在生产环境中,我们将通过重试、跳过动画或警告用户有关其连接问题而不中断游戏来处理一些错误。
- 如果我们无法处理错误并且无法恢复,则关闭引擎并让用户重新尝试启动游戏,而不使应用程序崩溃或显示晦涩的信息(这些信息会发送到 crashlytics)。
通过这,我们为游戏引擎添加了对所有现有和未来开发者可能加入的代码的错误处理功能,而无需使用任何 try-catch 语句。
最后的修饰我们快完成了!这篇文章已经有点长了,所以我来快速介绍一些为支持我们用例而安装的插件。
override val store by lazyStore(GameEngineState.Stopped) {
configure { /* ... */ }
// ...
val subs = awaitSubscribers() // 注释1
val jobs = manageJobs<GameJobs>() // 注释2
initTimeout(5.seconds) { // 注释3
subs.await()
}
whileSubscribed { // 注释4
assetManager.loadingProgress.collect { 进度 ->
updateState<加载, _> {
copy(进度)
}
}
}
install(
autoStopPlugin(jobs), // 注释5
resetStatePlugin(), // 注释6
)
}
- 由于开发人员可能会犯错误,启动游戏但从未显示实际的游戏体验(用户离开、有 bug、计划改变等),我使用了代码片段 (3) 中的预设
awaitSubscribers
插件,在游戏启动后的 5 秒内查看是否出现,如果没有出现,则关闭商店并 自动清理持有的资源 以防止泄漏。嘭! - 我使用了另一个插件——
JobManager
,在后台运行一些长时间运行的任务。使用它的代码未在此展示,但其主要作用是跟踪用户是否正在玩游戏。 InitTimeout
是一个自定义插件,用于验证游戏是否在 5 秒内完成加载,如果没有完成,则通知我们的recover
插件出错,它会决定如何处理并上报问题给分析工具。whileSubscribed
插件启动了一个 仅在有订阅者时才运行的任务 (在我们的情况下,订阅者是指 UI),这样我们只需在用户实际看到加载屏幕时更新加载进度的视觉效果。这使我们能够轻松地 避免资源泄漏,如果游戏引擎被其他内容遮盖或隐藏时。autoStopPlugin
通过使用我们的作业管理器来监视游戏加载进度和游戏进行进度。它会查看是否有订阅者来决定在用户离开时暂停游戏,然后 在引擎长时间未被使用时停止它,以消除内存泄漏的风险。resetStatePlugin
是一个内置插件,我需要安装它以在游戏结束时自动清理状态。默认情况下,商店在停止后不会自动重置其状态。这对于常规 UI 来说是好的,但在我们的情况下则不是——我们希望引擎在游戏结束时回到已停止的状态。
所有的插件都已经在库中了,使用起来轻而易举。
最后的总结这真是一场疯狂的旅程,但经过这一切之后,我不仅保住了饭碗,而且我觉得整体解决方案还挺不错的。引擎代码从7000多行减少到了400行易读、结构清晰、高效且可扩展的代码,用户们已经开始享受这些成果:
- 加载时间 从 20 秒减少到 1.75 秒!
- 崩溃 的游戏减少了从 8% 到 0.01%!
- 我们将游戏事件处理的 吞吐量 提高了 1700%
- 在游戏中由于缓存技术的使用,视频 缓冲 次数从约 31% 降低到不足 10%
- 游戏过程中 电池消耗 大幅度减少
- 游戏过程中 ANR 几乎消失
- 游戏过程中 GC 压力 减少了 40%
希望到現在為止,我已經說明了為什麼過去不喜歡的模式,如裝飾器(Decorators)、拦截器(Interceptors)和責任鏈(Chain of Responsibility),不仅在构建某些后端服务、网络代码或特定用例时,还包括实现普通应用逻辑,包括用户界面和状态管理时,可以非常有帮助。
利用 Kotlin 在构建 DSL 时的强大功能,我们可以将这些模式从繁琐的样板代码、继承和复杂的委托中解脱出来,转化为快速、简洁、紧凑且线性的代码,这样编写代码既有趣又高效。我鼓励你也为自己的应用架构构建类似的东西并从中获益。
如果你不想深入研究,或者想了解更多的话,可以考虑查看我在 GitHub(GitHub 页面)上实现这里提到的所有内容的原始库,或者直接进入 快速入门指南,在10分钟内试一下。
附:如果我们这篇文章能获得500个掌声,我的大脑就会受到激励,再写一篇文章,详细介绍如何在你自己的应用程序中实现插件系统。
共同学习,写下你的评论
评论加载中...
作者其他优质文章