使用 Codex 进行一次上万行代码的重构

462 阅读9分钟

最近使用 Codex 给 Fread 进行了一次大规模的重构工作,累计改动了一万多行代码,如果人工重构可能要持续一个多月的工作现在几天就完成了,现在回顾一下 Codex 工作的整体感受和一些使用技巧。

本次任务主要涉及两个方面,一是把依赖注入框架从原本的 kotlin-inject 改为 koin,二是项目中的导航框架用 navigation3 替换现有的 Voyager

使用 koin 的原因是我个人更偏向于 DSL 的方式来管理依赖注入,这样的代码更清晰,理解成本更低。此外,因为依赖注入涉及到的类非常多,注解不仅对这些类具有侵入性,也会生成太多的辅助类,其中的代码无法被直观的看到,使得整体注入流程像一个黑盒。

导航框架选择 nav3 是因为 Voyager 已经一年多没更新了,基本属于放弃状态,而且 nav3 的设计非常优雅,对于预测性返回以及 Shared Element 支持的也很棒,根本没有理由不用它。

所以可想而知,这是个非常大的工作,基本上所有的页面都要改,所有依赖注入也都要改。当然最终主要靠着 Codex 帮我解决了这个问题,据不可靠统计,差不多有 70% 的代码都是由 Codex 完成,本文就介绍一下使用 Codex 重构的整个过程。

这是本次改动的 PR:github.com/0xZhangKe/F…

充分利用 AI 的模仿能力

我目前觉得 AI Coding 最大的能力就是模仿,对于一个给定的模式,Codex 可以模仿的非常出色,即使在模仿的过程中偶尔出现一些例外情况 Codex 也可以随手解决。

所以本次重构我作为人类的主要任务实际上是找出所有不同类型的变更,然后将其分类处理,也就是说使用 kotlin-inject 依赖注入存在有限的几种使用方式,我针对这几种情况分别使用 koin 来替换重构,这样就存在了有限的特定场景下 kotlin-inject 与 koin 一一对应的情况,处理好这个问题后,我直接让 AI 分别模仿这几种情况下我写的重构代码,然后逐一修改。

这样做可以大大降低 Codex 面临的问题的复杂度,它只需要模仿我的代码,按部就班的解决剩下的问题即可,对于越复杂的问题 Codex 越容易出现错误,最开始我直接让 Codex 来重构依赖注入时它先跑了 1.5 小时,不仅耗光了好几轮的 Context,也耗光了五小时的用量,然后问题依然没有被解决,因为依赖关系实在是很复杂,它也几乎被绕晕了。

其次是这样的代码更加可控,因为至少我对于软件整体的理解比它要更深入,架构能力比它强,我知道正确的演进方向,所以我要发挥我的专长把复杂的问题先解决掉,剩下大量的重复性工作再交给它,咱们各司其职。

目前我使用 Codex 时,如果是一个比较大的任务,我基本上都会给它提供一个最佳实践的代码,让它参照最佳实践来工作。

特殊情况特殊处理

因为 Fread 是一个跨平台的 KMP 项目,依赖注入不涉及通用代码层,也存在很多平台实现层,这些情况叠加在一起问题就变得更加复杂了。

很多时候特殊情况我们自己只需要几行代码或者很短的时间内就能解决,但是 AI 则需要考虑很长时间,并且对于它来说复杂度会成倍增加。

Codex 一方面对于 KMP 项目了解的不多,另一方面对于平台实现层如何正确处理也不能提供一个很好的解决方案。对于这种特殊情况我的解决办法仍然是逐步解决问题,先人工编写部分代码,然后交给 Codex 解决剩下的部分问题,然后人工继续编写部分代码,Codex 再继续解决剩下的问题。

具体而言,对于每个存在依赖注入的模块,我都会声明一个如下的 expect 函数,并且在注册到当前的 koin 模块中。

expect fun Module.createPlatformModule()

val commonModule = module {
    createPlatformModule()    
}

然后再 Android/iOS 平台创建具体的实现:

actual fun Module.createPlatformModule() {
}

我先做完这一步,然后让 Codex 把所有模块都添加一个这样的改动。

上面的步骤完成后,就需要往这些平台实现的依赖注入模块内注册原有的 kotlin-inject 类。这样问题就简单多了,我只需要指导 Codex 把 kotlin-inject 模块中的声明同步到这个 koin 的平台级的模块内即可,这样的事情 Codex 可以完成的很出色。

然后重复上述步骤,完成所有模块的相关重构。

任务拆分很重要

由于 Codex 的上下文有限,解决一些复杂的任务时很容易会逐渐丧失初心,甚至给代码做一些奇怪的改动。

拆分任务的逻辑是对于复杂的任务,架构相关的工作仍然是交给人类完成,这部分工作完成后就可以继续拆分出独立且简单的子任务,这些交给 Codex 来完成,这样即使 Codex 犯错影响也不会很大。回滚代码时也更省 Token。

AI 生成的代码我个人习惯是一定会全部 review 之后再 accept,如果不做任务拆分那 review 的任务量就太大了,我自己的大脑怕是也难以承受。

复杂问题使用 SKILL

对于用 nav3 替换 Voyager 这个任务改动的代码非常多,其中包含了大量的重复性工作,就算是完全让 AI 来模仿我写好的最佳实践但是由于代码太多,任务比较复杂 Codex 仍然有可能出错,这时候我们就可以通过创建 SKILL 来解决这个复杂的问题了。

---
name: screen2navkey
description: convert Voyager Screen to navigation 3
---

## 任务背景
目前项目中使用了 Voyager(cafe.adriel.voyager:voyager-navigator) 作为导航框架,现在我希望将 Voyager 替换成 navigation3(androidx.navigation3:navigation3-runtime).

## 任务内容
目前 nav3 我已经集成并且完成了部分代码的重构,现在你需要帮我做一件事情,将一些 Screen 替换成 一个 Composable 函数 + NavKey。

你的目的是把继承自 cafe.adriel.voyager.core.screen.Screen 或者 com.zhangke.fread.common.page.BaseScreen 的类改成 androix.navigaton3 的一个 NavKey + 对应的 Composable 函数。

比如现在有这样的一个 Screen:

class ProfileScreen : BaseScreen() {

    @Composable
    override fun Content() {
        super.Content()
        val viewModel = getViewModel<ProfileHomeViewModel>()
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(MaterialTheme.colors.background),
        ) {
            Text(text = "Profile Screen")
        }
    }
}


那么你需要改成如下方式,并且新增一个 NavKey:


object ProfileScreenKey: NavKey

@Composable
fun ProfileScreen(viewModel: ProfileHomeViewModel){
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colors.background),
    ) {
        Text(text = "Profile Screen")
    }
}

但如果这个页面有参数,那么 key 也应该带一个参数:

data class DetailScreenKey(val itemId: String) : NavKey

@Composable
fun DetailScreen(viewModel: DetailViewModel){
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colors.background),
    ) {
        Text(text = "Detail Screen for item: $itemId")
    }
}


然后你需要把这个新增的 NavKey 注册到当前模块的 NavEntryProvider 中,比如:

class ProfileNavEntryProvider : NavEntryProvider {

    override fun EntryProviderScope<NavKey>.build() {
        entry<ProfileScreenKey> {
            ProfileScreen(koinViewModel())
        }
        entry<CreatePlanScreenNavKey> { key ->
            // with parameters
            CreatePlanScreen(koinViewModel { parametersOf(key.lexicon) })
        }
    }

    override fun PolymorphicModuleBuilder<NavKey>.polymorph() {
        subclass(ProfileScreenKey::class)
        subclass(CreatePlanScreenNavKey::class)
    }
}


## 工作流程
你需要 Follow 以下工作流程:
1. 首先找到给定模块中所有符合如下条件的 Screen:
    a. 继承自 cafe.adriel.voyager.core.screen.Screen 或者 com.zhangke.fread.common.page.BaseScreen
    b. 不包含任何嵌套 **Navigator**
2. 将这些符合条件的 Screen 列出并输出到控制台
3. 逐个重构这些 Screen
4. 对于每个 Screen,首先创建该 Screen 的 NavKey,比如给 ProfileScreen 创建一个 ProfileScreenNavKey.
5. 将 ProfileScreen 改为 @Composable 函数。
6. 对于使用了 navigationResult 的地方请保持不动,不要试图修改相关的代码,即使有编译报错也不用管,保留原样。
7. 将 ProfileScreenNavKey 以及这个 @Composable 函数 注册到该模块的 NavEntryProvider 中。
8. 找到这个 Screen 的相关引用,并将跳转处改为这个 Screen 的 NavKey
9. 结束这个 Screen 重构并进入下一个 Screen。
10. 直到所有重构完所有满足条件的 Screen。

## 绝对禁止
一下内容为绝对禁止修改的规则:
1. 对于已经修改完成的类请不要再改
2. 你只应该修改 Screen 和 navigation3 相关的代码,其他的代码不要改,即使你觉得有问题也不要改
3. 不要做任何超出我要求的事情
4. 遇到不属于上述情况的页面请直接忽略,不要自己想办法解决
5. 不要求改任何嵌套的 Navigator 页面,遇到嵌套的情况直接跳过
6. 不要修改任何已经使用 navigation3 的页面
7. 不要通过代码引用的方式找某个页面的引用并且试图修改其引用点
8. 不要修改任何超出要求的代码

在这个 SKILL 中我写了一些绝对禁止的行为,其实就是在规避特殊情况,也就是上面提到的特殊情况特殊处理,这样可以极大的降低任务复杂度。

如果我们定义了具体的工作流程,并且要求 Codex 必须遵守,那么 Codex 出错的可能性就会小很多。

单元测试验证

我们也可以先让 AI 针对任务编写足够多的单元测试,并且使重构前的代码全部通过单测,然后进行大规模的重构后再次运行单元测试,以此保证软件稳定性。不过 Fread 本次重构涉及到了很多 UI 代码,单测比较麻烦就没做。

提交并 Review

根据我们上面的步骤,每次一个小的任务完成后都可以创建一个提交,然后创建一个新的对话任务来 review 这个提交,创建新的 Threads 是因为要丢掉原本的 Context,作为一个全新的任务交给 AI,否则它可能为自己的问题自圆其说。