MoreRSS

site iconZhangKe | 张可修改

软件开发工程师,目前主要做 Android、Kotlin、KMM 开发。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

ZhangKe | 张可的 RSS 预览

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

2026-02-03 10:35:15

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

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

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

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

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

这是本次改动的 PR:https://github.com/0xZhangKe/Fread/pull/86

充分利用 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,否则它可能为自己的问题自圆其说。

The post 使用 Codex 进行一次上万行代码的重构 first appeared on 张可的博客.

成为父亲的这一年

2025-11-20 13:39:39

2024 年真的发生了好多事情,我们搬到了属于自己的家、我过了三十岁生日以及我们有了一个孩子。宝宝是在冬天的出生的,临近元旦,赶上了龙年的最后一个月。

为什么要生孩子?

乐观的说,我决定生孩子的原因之一就是我很好奇我最终会培养出来一个什么样的孩子,我也很期待有一天能和孩子一起聊天,一起学习,陪伴他成长的过程应该会很快乐。费曼学习法的核心就是你要把你学到的知识向别人表达出来,那么教育孩子这件事情就是要把我的人生向孩子表达出来。

当然还有一点,我不确定,但我总觉得如果有一天我老了,随着激素水平的下降和身体日渐衰弱,我会逐渐感觉到世界的虚无,因为我一直是一个热爱生活热爱这个世界的人,但如果真的有那么一天,我感受到了这种虚无和空洞,我完全不知道要如何接受这一切。但孩子至少会是我和这个世界的一个连接,支撑着我继续活下去。

但是生孩子这件事情还能有什么更高尚的原因吗?我好像没想到,生孩子是自私的,因为做出生孩子决定的时候孩子并不存在,我们无法寻求他的意见,所以他无论如何没办法在主观上希望我把他生下来,既然有了这层因果关系存在,那么选择生孩子这件事情上就一定存在道德瑕疵。所以我也不会通过什么我给了你生命养育你长大之类的观点来给他加上一层原罪好让他以后孝敬我。

教育

好在目前为止我们仍然没有要鸡娃的打算,我们也不会给娃的未来抱有某些功利的期望,不过养娃仍然是一件困难的事情,主要原因在于,一方面我们自己成长过程中已经习得了很多不好的习惯,包括性格缺陷种种,我们不希望在孩子面前继续表现出来,不然孩子也会自然的学到。另一方面来源于外部,东亚国家尤其是咱们这里,社会环境和学校教育也都存在相当多的问题,这些问题和我们的教育之间必然会产生矛盾,如何让孩子在这其中保持平衡我觉得应该很困难。

其实我们对孩子的期望非常简单,就是身体健康和精神健康。身体健康花钱解决,精神健康是我们作为家长要认真研究的事情,我觉得作为一个健全的人要学会正确的思考,要有同理心,要有一定的知识,其他的就按照他自己的意愿吧。

好在现在孩子才一岁,刚刚说的这些都是未来需要考虑的事情,但现阶段也有现阶段的难题。得益于移动互联网的发展,商业公司无时无刻不在考虑如何掠夺用户的注意力而不考虑后果,现在即使是三岁小孩也可以刷抖音和玩王者荣耀,这种产品对孩子的大脑是会产生物理上的损伤,以及为了应对婴幼儿没有产品可以抢夺注意力的缺口自然也有商家发明了各种声光电玩具,总之它们希望孩子的注意力越短越好。我不太能接受的是孩子会失去作为一个人类应有的思考能力,不管是知识的获取还是复杂的思考注意力都是前提,现在我们也尽可能培养孩子独立玩耍独立探索的能力,尽可能减少声光电玩具。

变化

有了孩子之后人生会发生巨大的变化,即使已经过了快一年了我们还是没有习惯这样的生活。

首先这是我们身份的巨大转变,我们现在是绝对意义上的中年人了,一点也没办法再以年轻人自居了,社会不会像对待年轻人一样对待中年人,而且中年人有中年人的行事风格,有中年人的压力和焦虑,好像过了三十岁,有了孩子之后突然就变成了中年人,毕竟不得不考虑孩子带来的责任感。

当然随之而来的还有人生的重点,什么人生的意义个人价值自我实现在养孩子面前都得往后稍稍,我们深刻的明白养孩子意味着什么,因此我们得完全承担起这个重大的责任,不仅要保证孩子身体健康,还要保证精神的健康,还要尽可能的为他的未来考虑,这是一件没有上限的事情。

实际一点的变化就是个人时间消失殆尽,我差不多每天六到七点左右下班到家,然后到晚上十二点还有五个多小时,以前这个时间完全属于我自己,可以看两部电影,或者写点代码看点书。但有了孩子之后情况就不一样了,首先要带娃直到晚上八点多他睡觉,然后中间他可能会醒,我们就得去冲奶粉,不仅如此,还有一些孩子的家务活比如洗衣服洗奶瓶之类的。然后因为孩子醒得早,差不多早上六点多就醒了所以我就要在十点左右睡觉。所以晚上差不多只有一个多小时自己的时间了,还不够看个电影的。

前三个月孩子只能在小区里推着婴儿车转转,公园都不太能去,因为要睡觉喝奶换尿不湿。再稍微大一点的时候可以坐车了,然后我们才能去车程三小时内的地方玩玩,但仍然要带一大堆东西。现在快一岁了,我们终于可以带他爬山,我用个背带背在我胸前,他可以直接在我怀里睡觉或者看看风景,我俩这才算喘口气,不过这样出去玩也是很累,毕竟背着一个快二十斤重的宝宝爬山。

小婴儿对父母是非常依赖的,尤其是在前三个月妈妈几乎是寸步不离,直到现在晚上基本上也只能妈妈哄睡,偶尔可以让奶奶哄睡,我俩出去找点夜生活。好在现在周末白天基本上可以让奶奶带着我们俩在苏州玩玩,所以十全街观前街这种人挤人的地方我们也还是去了,附近的商场和公园也都去了很多次,算是苦中作乐。

好处

好处的话也是有的,生孩子会带来一些全新的前所未有的体验,可能所有的家长都一样,真的完全没办法客观评价自家孩子的颜值,虽然我的孩子眼袋很大但我仍然觉得他是最可爱的宝宝,他的一举一动都太可爱了,是那种发自内心的无法控制的可爱,就算单纯的看着他坐在那里也会感觉虽然不知道为什么但就是很可爱。

然后也会多出来很多作为家长才会有的情感吧,孩子对父母的依赖也会让父母产生幸福和成就感,有时候我们俩想单独出去玩不带孩子但是刚到楼下就已经开始想他了,然后会觉得愧疚,不应该把他留在家里,但孩子也要学会长大,不得不面对这样的情况。

写到这里我突然想到,未来人类总会逐渐脱离碳基生物这个设定的吧?总会不需要怀孕这么久才分娩然后养十八年才成人吧?那时可能就有了生化人或者意识上传,人类实现了永生,那我们现在死的这波人岂不是太可惜了?眼看着马上就能永生结果死在了门口。

The post 成为父亲的这一年 first appeared on 张可的博客.

梦中的两次濒死体验

2025-10-28 13:49:01

我做过很多稀奇古怪的梦,其中很多都和现实关联不大,但是我的噩梦和现实世界一般都有很强的关联。我因为身体健康的原因,一直有着很大的心理负担,有时候压力很大。也可能是因为死亡焦虑,我最近做了两次和死亡有关的噩梦,这也应该是我距离死亡最近的两次。

梦中的世界有个奇怪的设定,这里的人可以自由的选择安乐死,不管是因为疾病还是别的原因,你可以轻易的选择死亡。社会上也因此提供了执行死亡的机构,自愿安乐死的人都要到这里来排队取号,这个机构有点像体育馆或者音乐馆,建筑物中间是一个很大的空地,大约两个篮球场这么大?周围是一些办公室小房间,在进入场馆之前需要签署一系列的协议,然后携带好身份证和协议进入场馆。

进入场馆意味着你将会彻底和这个世界断绝联系,所有人都会扔掉所有的随身物品包括手机,换上统一的白色服装,你也可以选择进入后直接安乐死,也可以选择在馆内住几天,周围的小房间就是给人睡觉用的,这个场馆就像是死亡和活着的中间地带,你不能与外界进行任何联系,等待你的也只有死亡。

我不知道什么原因也选择了安乐死,签署完协议后我就开始排队进入场馆,我前面是一个中年男人,带着一个洗脸盆,里面放了毛巾牙刷之类的,他有家庭和孩子,因为进入后就不能带手机了,所以这个男人在打电话给她的老婆,大概内容是孩子的书白天被扔到桌子下面了,记得捡起来之类的,总之就是很日常的交流,丝毫不像马上就要死的人。

但我在此时产生了巨大的恐惧,我突然意识到我马上就要死了,彻底与这个世界断绝联系,一切全都要消失了,我面前的男人肯定也是这样,但此时他仍然在交代这些日常琐事,但这些琐事是我们和这个世界最紧密的联系,我们在乎着这个世界的每一处细节,但问题是我们马上就要死了,彻底的消失,我非常恐惧,我害怕死亡,害怕消失,害怕与这个世界断绝联系,然后我就被巨大的恐惧惊醒了,过了好一会才缓过来。


另一个是这几天的噩梦,梦中不知道什么原因我被人追杀,我疯狂逃命,没有任何能安全的地方,中间找到了一个角落我躲在那里终于才有了喘口气的机会,此时我突然想到我为什么不反杀回去呢?最好的防守就是攻击,于是我找到了追杀我的人,一刀杀死了他。

然后我整个人变得很轻松,很开心,我就骑上我新买的公路车出去玩,骑了一会突然觉得不对,我毕竟刚刚杀了人啊,这完全是犯法的行为,我应该立即去自首,表现好的话可以少判几年,我就去自首,警察当着我的老婆孩子的面宣判我要监禁三十到四十年左右。

这时候我想到了我刚出生的孩子,我的老婆,以及他们后面该怎么活下去这一切,然后想到了我几乎再也见不到她们了,也将彻底从她们的生活中离开,也无法对她们的生活提供任何帮助,想着想着我开始害怕,我想这和死亡有什么区别,这对我来说就是死亡,我害怕他们没法好好的生活,我也害怕我进入了监狱就意味着要离开这个世界,但是我马上就要进监狱了,我也做不了任何改变,这是无法改变的事实,这是确定的未来,我真的非常恐惧。

也大概就在这个时候我从梦中惊醒,现实世界的情况逐渐回到我的大脑,我开始意识到这是个梦,心情才逐渐平复。第二晚我甚至有点害怕入睡,害怕继续做噩梦。

这两次梦带给我很大的冲击,也是我目前遭受到的最接近死亡的情况,只是我还年轻,现在还没办法平静的看待死亡这个事情,或许等我老了就可以了吧。

The post 梦中的两次濒死体验 first appeared on 张可的博客.

Fread 最新版功能介绍

2025-10-22 22:18:36

距离 Fread 上架已经一年多了,上架后一直在不断的优化产品体验和提供更加丰富的功能,近期也有一些比较大的改动,所以这里介绍一下目前 Fread 最新版本的主要功能。

目前 Fread 对于长毛象的支持已经比较完善了,大部分的功能都已经实现,Bluesky 方面主要是缺少消息推送和私信功能,对于发布帖子以及浏览交互方面基本都已经支持,RSS 的协议比较简单所以支持起来不麻烦,只不过缺少自动拉取的功能,未来会考虑加上这个功能,也可能会提供后台服务用于加速获取内容。

主要的功能和改动如下:

  • Onboarding 流程优化
  • 添加未登录的 Mastodon 内容
  • Pixelfed 支持
  • 混合 Feeds(RSS+Bluesky+Mastodon)
  • Profile Tab UI 大改版
  • 搜索自己或别人发布过的帖子
  • 用户详情页改版
  • 同一个实例登录多账号
  • 多 Tab 选中账号自动同步
  • 将一个帖子同时发布到多个账号
  • Mastodon 引用帖子
  • 消息通知改版
  • 通过其他账号打开帖子
  • 默认打开的时间线位置
  • 帖子内容大小设置
  • 动态颜色
  • 更多的本地化支持
  • 新增捐赠渠道
  • Roadmap

Onboarding 流程

近期做的比较大的改动是在添加内容时提供三个内容类型让用户选择,选择完成后进入下一步开始添加具体的内容信息,以前是只需要用户输入任意的内容,Fread 根据用户的输入来判断进入哪种内容的添加流程,虽然这样的操作更简单但是我发现很容易给用户造成困惑,很多人并不知道应该如何添加自己期望的内容类型,因此 Fread 直接提供了选择类型的页面,这样更容易理解,也就是下图这个页面。

另外在内容的添加页面中的搜索功能也做了完善,现在你可以在输入框中输入用户名或者用户的 Handle、WebFinger、Home Page URL 都可以搜索到用户,也可以直接输入实例名搜索实例。

未登录内容的支持

Fread 还有个功能得到了一些用户的喜爱,也就是可以在不登录的情况下浏览一个 Mastodon 实例的内容,虽然不同的实例对登录状态都有不同的限制,但是很多实例允许在未登录的情况下浏览本地时间轴和跨站时间轴,对于不想注册更多账号但同时又希望浏览其他实例内容的用户来说这很有用。

使用方式也很简单,在添加实例的最后一步时不要点击下面的登录按钮直接返回即可,因为当你看到 Fread 提示添加成功时就表示实例已经被添加到 Fread 中了,此时不登录也是可以在首页的抽屉栏中看到的。

Pixelfed 支持

因为 Pixelfed 也是支持 ActivityPub 协议的,所以兼容起来比较容易,只不过 Pixelfed 对 ActivityPub 的协议是部分支持,所以刚开始遇到了一些问题,但很快就解决了。

目前 Fread 是已经可以当作 Pixelfed 客户端来使用了,未来会考虑支持 Pixelfed 视图模式,也就是 Timeline 中的帖子显示方式会按照 Pixelfed 的 UI 来做。

混合 Feeds

从一开始 Fread 的设计目标之一就是打通各个平台的内容壁垒,所以 Fread 一直都是支持创建混合 Feeds 流的,在添加内容时选择 Mixed Content,然后就可以添加不同的内容源,不仅仅是 RSS,也可以是 Mastodon 的用户或者 Bluesky 的用户,创建完成后 Fread 会打造一条包含了所有源的 Feeds。

Profile 模块

Fread 一个主要的特色功能就是对多账号的支持更加友好,这主要体现在交互方面,所以用户不需要进行任何切换账号的操作,既然如此,那么所有已登录的账号实际上都是平等的,都会平铺显示在 Profile Tab 内。

Profile 作为首页的四个 Tab 之一也做了很大的改动,主要是对 UI 的优化,首先是移除了每个账号底部的几个快捷操作按钮,主要是这些按钮放在这里有点意义不明,并且也不是高频操作,这些功能直接进入用户详情页同样可以做到。

另外就是对账号卡片样式的优化,新增了 Banner 的显示和关注信息,改动后的样子如下图所示。

多 Tab 选中账号自动同步

仔细看的话你会发现上图中间账号的用户名后面有一个绿色的点,这也是最近新增的功能。

在首页的前三个 Tab 中用户都可以通过滑动或者 Popup 内切换内容或账号,比如你在首页查看账号 A 的内容时可以右滑切换到账号 B 的内容,或者点击左侧抽屉栏切换内容,那么此时也就意味着你当前正在浏览的账号主体发生了变化,那么相应的其它 Tab 现在也会切换到对应的账号主体。而 Profile Tab 中的绿点就表示当前的账号主体。

搜索指定用户帖子功能

现在在用户详情页的顶部新增了一个搜索按钮,可以用于搜索这个用户发布过的帖子(包括自己),不过由于 Mastodon 本身的限制,搜索别人的帖子时有可能无法全部搜索出来。

用户详情页改版

用户详情页现在主要的改动是移除了原本的“关于”Tab,“关于”部分的内容上移到头部显示。然后新增了点赞和收藏 Tab。

同一个实例登录多个账号

如果你在一个实例有多个账号,那么现在可以同时登录 Fread 了。

最初的版本不支持主要是因为架构设计上为了方便就当做这种情况不存在,但后面发现有一些用户确实存在这种需求(包括我自己),所以花了点时间给账号系统做了一些重构。

发布帖子

发帖部分一个重要的改动是支持了多账号同时发帖,也就是说你可以把一条帖子同时通过多个账号发布,页面入口在发帖页面的右上角,有一个添加用户的 icon button,点击后进入如下页面。

你可以在此处选择多个账号,发帖的配置(字数、语言、媒体)会根据你选择的账号列表而有所不同。

未来我还想支持添加 X 账号,因为 X 的发帖接口似乎是免费的,如果能添加进来至少对我来说是个很好用的功能。

另外还有一个变化是对于 Mastodon 发帖来说,当你选择了图片之后并不会立即开始上传,而是点击发布后开始上传,这个主要是为了未来新功能做打算,未来计划点击发帖按钮后直接退出当前页面,在后台请求网络。

引用帖子

虽然引用帖子功能在毛象内被反复讨论了很多次,但最终 Mastodon 还是支持了引用帖子的功能,由于 Bluesky 本来就支持引用功能,所以 Fread 实际上在显示引用贴和发布引用贴方面都是已经有了的,所以现在很迅速的支持上了这个能力。

首先在发帖页面,如果账号所在的实例支持引用功能,那么发帖页面会有一个设置引用权限的设置按钮用来设置这个帖子的引用权限范围。

在引用其它帖子方面,如果当前账号主体所在实例支持引用功能,那么点击帖子的转发按钮将会出现一个弹窗用于选择转发或是引用,对于你不具备引用权限或者该帖子所在实例不支持引用的情况,引用按钮将置为不可点击状态。

通知改版

通知页面的 UI 也做了一些改动。

一是移除了未知消息类型,由于 Mastodon/Bluesky 的接口变化,时常会出现一些新的消息类型,以前是直接把接口返回的 Json 显示出来,虽然有一定的作用但是看起来很丑陋,所以现在去掉了,未知类型消息不显示,取而代之的是我会定期看接口文档加上新增的消息类型。

二是通知 UI 的改动,主要是对于新增关注用户的通知,现在卡片样式更加丰富,与 Profile Tab 的账号卡片类似,同时新增了 Follow Back 按钮。

此外还新增了引用相关的通知类型。

帖子

帖子的样式其实已经不太会出现很大的改动了,主体基本保持不变,细节上做了一些优化,对于回复贴新增了 Thread Label。顶部的 Label 位置做了改动,字体、边距、颜色这些都有一些调整。

通过其他账号打开帖子

对于任意一个帖子,用户可以通过点击更多按钮选择“通过其他账号打开”,然后会出现一个包含了当前登录用户列表的弹窗,用户选择其中一个用户后 Fread 会以该用户为主体打开这个帖子的详情页,这样可以快速的以多个身份对同一个帖子进行交互操作。

默认打开的时间线位置

以前 Fread 的逻辑是每次打开首页时间线时定位到用户看过的最新的那条帖子,但实际使用下来体验并不好,现在在设置中提供了选项用于设置这个默认行为,其中包含两个选项:最新位置和上次阅读位置。

对于本站时间轴跨站时间轴和趋势榜都是会打开最新的帖子,这里的设置是应用在首页时间轴的。

帖子内容大小设置

Fread 提供了三种帖子内容的尺寸,用户可以在设置中选择合适的大小,这里的大小不仅会应用到文本,也会应用到图片,边距等方面,以此保持相对合适的视觉体验。

动态颜色

前段时间 Fread 提供了对动态颜色的支持。

Android 12 中新增的“动态配色”功能可让用户根据个人壁纸的配色方案或通过壁纸选择器中的所选颜色,对设备进行个性化设置,以实现色调一致。

现在用户可以在设置中点击 Theme color 选择 Follow system,这样 Fread 的配色就会跟随系统配色,从而保持整体色调的一致性。我自己体验下来效果还是不错的,比固定的颜色要漂亮。

不过 Fread 中的主色调和 tertiary 色调是固定的,不会随着设置而发生变化。

本地化

目前 Fread 已经支持了 10 种语言,支持的语言如下:

  1. 英语
  2. 简体中文
  3. 繁体中文-台湾
  4. 繁体中文-香港
  5. 德语
  6. 西班牙语
  7. 法语
  8. 日语
  9. 葡萄牙语
  10. 俄语

内容拖动排序

防止有人不知道,Fread 首页左侧的抽屉栏中的内容列表是可以拖动排序的。

另外点击内容右侧的设置按钮进入内容设置页面也是可以拖动排序的。

如果内容是 Bluesky 的话设置页面仍然可以拖动排序,这里的体验要比 Bluesky 官方 App 好很多。

不过 Mastodon 和 Tab 拖动排序和 Bluesky 的 Tab 拖动排序有个区别,Mastodon 因为官方并未提供排序的能力,因此这里的排序是保存在本地的,而 Bluesky 官方提供了针对 Tab 的排序能力,所以这里的排序会调用 Bluesky 的接口进行排序,也就是说会影响到网页端和官方 App。

开源

Fread 在今年变为开源项目了,项目地址是:

https://github.com/0xZhangKe/Fread

Roadmap

目前虽然没有什么明确的规划,但短期内会有一些要做的事情,但是也不能确定具体的时间和排序。

  1. 帖子列表翻译,借助第三方翻译接口。
  2. 支持通过 Deep Link 打开 Bluesky 或 Mastodon 链接。
  3. 本地 AI 生成图片替代文本(其实已经做了,但是我没有测试设备就一直没上)。
  4. 后台发帖。
  5. 发帖图片手动设置压缩。
  6. 全屏播放视频页面默认是否静音设置。

远期一点的规划:

  1. iOS 版本,因为用了 KMP 所以 iOS 版本支持起来并不会很复杂,但目前肉眼可见的有不少需要适配的工作量,就一直懒得搞了。
  2. 登录 X 账号用于联合发帖。
  3. 加速 RSS 内容或者混合内容获取。

新增捐赠渠道

真的有 0 个人在意这一点吧。。不过我还是说一下,现在捐赠渠道新增了国内爱发电了。

The post Fread 最新版功能介绍 first appeared on 张可的博客.

Kotlin 函数式编程思想

2025-09-05 09:04:06

作为一个使用了很多年 Java 的开发者☕️,面向对象思想早已深入人心,但 Kotlin 语言本身是天然支持函数式编程的,如果你对此没什么感觉,那么请多用 Compose 。

但是 Kotlin 的函数式编程思想并不是仅仅可以在 Compose 上发挥作用,我们在任何地方都可以使用函数式编程来解决问题。相对于面向对象,函数式在很多时候都具有独特的优势,我们可以灵活的在合适的地方用合适的方式来编写代码。

倒不是说面向对象不好,面向对象是用来解决特定问题的,它可以很好的对事物做抽象和建模,但我们也不能陷入面向对象的条条框框之中。

在 Android 开发中的函数式也有一些特有的问题需要解决,本文主要就是来介绍 Kotlin 函数式编程以及 Android 开发中的函数式编程。

函数式最早可以追溯到一百多年前,数学家希尔伯特提出了著名的希尔伯特计划:希望建立一种严格的数学基础,保证所有数学问题都能通过形式化方法解决。其中的关键问题之一就是可判定性问题(Entscheidungsproblem)。 这个问题在 1936 年被两个人同时解决,其中一个人是图灵,另一个人就是阿隆佐·邱奇。

邱奇在 1930 年代开发的 λ 演算,是建造自函数应用的一种计算形式系统。在 1937 年,艾伦·图灵证明了 λ 演算和图灵机是等价的计算模型,展示了 λ 演算是图灵完备性的。λ 演算形成了所有函数式编程语言的基础。

Kotlin 并不像 Haskell 一样是纯函数式编程语言,而是混合式的,其语法层面上是都支持的,我们可以灵活的切换编程方式,扬长避短。

函数式编程的概念

函数式编程是一种编程范式,将函数作为一等公民(first-class citizen),避免使用程序的状态和定义对象及变量,函数可以作为参数也可以作为返回值,程序的运行则是利用若干简单的函数计算逐级推导至复杂运算,最终完成计算过程,返回计算结果。

顶级函数

在 Kotlin 中我们会有个顶级函数的概念,也就是在一个 kt 文件中直接声明函数,而不是将函数声明在类的内部。这意味着我们可以直接把函数当作一段逻辑计算的入口,不需要通过类创建对象这样的方式,这也是函数式中的重要概念,也可以说是一个支持函数式编程语言的必备特性。

依赖注入怎么办?

如果都使用函数,那么就要解决依赖注入的问题,不过我知道的有些依赖注入框架是支持函数注入的,你可以直接在函数中加上参数,然后依赖注入框架会直接帮你注入。

typealias myFunction = () -> Unit

@Inject
fun myFunction(dep: Dep) {
}

当然我们的目的并不是打造一个纯函数式编程程序,既然是混合,那么类是完全可以接受的,我们仍然可以在类中使用依赖注入。

不过 Kotlin 似乎也考虑到了这一点,所以提供了 invoke 运算符重载,用过 UseCase 的人都知道这是怎么回事。

class UpdateActivityPubUserListUseCase @Inject constructor(
    private val contentRepo: FreadContentRepo,
) {
    suspend operator fun invoke(
        content: ActivityPubContent,
        allUserCreatedList: List<ListTimeline>
    ) {
        // do something
    }
}

看起来就是个普通的类,可以通过依赖注入在构造器内注入参数,区别在于使用了 operator 修饰符,重点是使用的时候可以像一个函数一样使用。

class XXXViewModel @Inject constructor(
    private val updateActivityPubUserList: UpdateActivityPubUserListUseCase,   
): ViewModel(){
    
    init {
        viewModelScope.launch { 
		        // It’s actually calling the invoke method.
            updateActivityPubUserList()
        }
    }
}

函数类型

函数作为一等公民的意思是函数可以作为一种变量的类型而存在,它并不会因为是函数而有什么特别之处,函数可以作为变量存储、作为参数传递、作为返回值。

val add: (Int, Int) -> Int = { x, y -> x + y }
println(add(2, 3)) // 5

函数也可以接收其他函数作为参数。

fun operate(x: Int, y: Int, f: (Int, Int) -> Int): Int = f(x, y)
println(operate(2, 3, { a, b -> a * b })) // 6

总之在 Kotlin 中使用函数非常灵活,虽然 Kotlin for JVM 也会把函数编译成类对象,但这毕竟是 JVM 的限制,而 Kotlin 本身是跨平台的,只要在源码层面可以将函数视为一等公民那就没什么问题了。

纯函数与幂等函数

幂等函数和纯函数都是函数式编程中的重要概念。幂等函数是指同样的输入多次执行结果保持不变。纯函数是指输入相同的参数输出的结果必然保持一致,并且没有副作用。

一个干净整洁的函数对于函数式编程来说非常重要,因为在函数式中函数的定义是一段特定的逻辑计算,它只接受函数的入参作为计算的依赖项,然后返回计算结果,通过连续地函数调用来完成整个复杂任务。除此之外不应该有任何其他的操作。既不应该依赖外部的状态,也不应该修改外部的状态。

副作用(Side Effect)

写过 Compose 或者 React 的开发者应该对副作用的概念很清楚,在声明式 UI 框架中,UI 部分一般都是通过幂等函数来实现,但实际上我们总有一些 UI 之外的事情要做,或者我们总希望能获取到某些 UI 的信息或者时机,但是作为幂等函数我们是不能修改外部变量的,因此提供了专门的 API 来管理副作用。

也就是说,副作用可以理解为幂等函数运行过程中的一些额外的影响,为了保持幂等性,我们需要通过副作用 API 将本次运行函数的影响暴露出来。

@Composable
fun UserDetailPage(viewModel: UserDeailViewModel){
    LaunchedEffect(Unit){
        viewModel.onPageResume()
    }
    Scaffold { 
        // ...
    }
}

不过,严格来说,Composable 其实并不能算是纯函数或者幂等函数,他们只是在设计上尽可能按照这个方向来设计,思想上保持一定程度的一致性。因为,首先将 UI 绘制到屏幕上本身就是一种副作用,所以 composable 的目的本来就是为了产生副作用。其次,composable 并没有返回值,不存在相同的输入就有相同的输出,可以说绘制 UI 就是 composable 的输出。只不过我们先了解了函数式编程后再来理解 composable 就容易多了。

不可变性(Immutability)

函数式编程中的 不可变性 (Immutability) 指的是:一旦数据(属性、对象、集合)被创建,就不能被修改。如果数据发生变化那么应该创建一个新的数据,而不是在原值上改动。这样保证了函数调用之间不会“互相污染”,也避免了一些隐藏的副作用。

这样我们很自然的就想到了 Kotlin 中的 valdata class ,通过 val 声明的属性是不可变的,一般来说 data clas 也是不可变的,Kotlin 虽然没有严格的要求,但是强烈推荐 data class 中的所有属性都必须使用 val 声明,否则会出现一些奇怪的问题。

并且 data class 提供了 copy 方法用于复制一个对象副本,在复制的时候可以修改其中的某些属性,这也符合函数式编程中的不可变性。

此外 Kotlin 中的集合默认也都是不可变集合,像 *arrayOf*() , *listOf*() , *mapOf*() 这种方式创建的集合都是不可变集合,考虑到方便的创建不可变集合,Kotlin 还贴心的提供了 buildList{ } 这样的函数防止我们为了省事创建了可变集合。

所以我现在在开发过程中已经很少使用变量了,如果看到某个地方声明了一堆 var 真的会引起生理不适🤮。这样的好处就是心智负担很低,不用担心这么多变量组合出千变万化的状态,整个程序逻辑变得异常清晰。

毕竟,多个变量共同作用组合出的状态可以说是指数级增加的,整个系统也会变的更加难以理解难以测试,实际上函数式思想就是将软件变成对数据的计算过程,如果合理的设计函数,那么可读性也会大大提高。

当然了,我们开发软件大部分使用的仍然是面向对象,函数式还是作为面向对象的补充,并且其中的一些思想完全可以借鉴,比如我们虽然还是要创建一个类,但是其中的属性可以尽量不可变,也不必排斥顶级函数,这不是什么语法糖🍬,这是函数式编程的体现。不用坚持某些教条主义,在实践中灵活的改变编程范式或许能让代码更优雅更简洁。

The post Kotlin 函数式编程思想 first appeared on 张可的博客.

在 Voyager 中使用 SharedElement 共享元素动画

2025-08-05 09:42:02

Jetpack Compose Animation 前段时间终于支持了共享元素动画,这很大程度上提高了 Android 应用的用户体验,也降低了开发门槛,以前通过布局计算实现的方案既不优雅也很麻烦。

共享元素动画虽然和导航框架没关系,但是由于其使用上的一些限制,所以导航框架如果能直接支持是最方便的,目前在 Jetpack Navigation 中是已经支持的了,但是 Voyager 没有官方支持,好在 Voyager 的接口足够灵活,我们可以通过一些自定义的方式使其支持共享元素动画。

首先简单介绍一下共享元素动画。共享元素动画是指在不同的布局或者屏幕切换时共享其中的部分 UI 元素,被共享的元素会通过动画随着布局/页面切换过去,从而在视觉上提供一个很漂亮的专场。

在 Compose 中,有几个高级 API 可以帮助你创建共享元素:

  • SharedTransitionLayout:实现共享元素转换所需的最外层布局,它提供了一个 SharedTransitionScope ,Composable 需要在 SharedTransitionScope 中才能使用共享元素修饰符。
  • Modifier.sharedElement():这个修饰符用于向 SharedTransitionScope 标记该 Composable 应该与另一个 Composable 进行匹配。
  • Modifier.sharedBounds():这个修饰符用于向 SharedTransitionScope 标记该 Composable 的边界应该作为转换发生的容器边界。与 sharedElement() 不同,sharedBounds() 是为视觉上不同的内容而设计的。

使用方式如下:

var showDetails by remember { mutableStateOf(false) }
SharedTransitionLayout {
    AnimatedContent(
        showDetails,
        label = "basic_transition"
    ) { targetState ->
        if (!targetState) {
            MainContent(
                onShowDetails = {
                    showDetails = true
                },
                animatedVisibilityScope = this@AnimatedContent,
                sharedTransitionScope = this@SharedTransitionLayout
            )
        } else {
            DetailsContent(
                onBack = {
                    showDetails = false
                },
                animatedVisibilityScope = this@AnimatedContent,
                sharedTransitionScope = this@SharedTransitionLayout
            )
        }
    }
}

@Composable
private fun MainContent(
    onShowDetails: () -> Unit,
    modifier: Modifier = Modifier,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    Row {
        with(sharedTransitionScope) {
            Image(
                painter = painterResource(id = R.drawable.cupcake),
                contentDescription = "Cupcake",
                modifier = Modifier
                    .sharedElement(
                        rememberSharedContentState(key = "image"),
                        animatedVisibilityScope = animatedVisibilityScope
                    )
                    .size(100.dp)
                    .clip(CircleShape),
                contentScale = ContentScale.Crop
            )
        }
    }
}

@Composable
private fun DetailsContent(
    modifier: Modifier = Modifier,
    onBack: () -> Unit,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    Column {
        with(sharedTransitionScope) {
            Image(
                painter = painterResource(id = R.drawable.cupcake),
                contentDescription = "Cupcake",
                modifier = Modifier
                    .sharedElement(
                        rememberSharedContentState(key = "image"),
                        animatedVisibilityScope = animatedVisibilityScope
                    )
                    .size(200.dp)
                    .clip(CircleShape),
                contentScale = ContentScale.Crop
            )
        }
    }
}

如何在 Voyager 中使用?

首先需要解决如何传递 SharedTransitionScope 以及 AnimatedVisibilityScope 的问题。因为 sharedElement 动画需要用到这两个 scope,如果一路传递下去很麻烦,因此我们可以通过过 CompositionLocalProvider 将其传递下去。

val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }

val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }

因为 sharedElement 必须发生在 SharedTransitionLayout 内部,即使是不同的页面也需要这样,所以还需要在 Navigator 外层包一个 SharedTransitionLayout

SharedTransitionLayout {
    CompositionLocalProvider(
        LocalSharedTransitionScope provides this
    ) {
        Navigator(
            screen = remember { FreadScreen() },
        ) { navigator ->
            CurrentAnimatedScreen(navigator)
        }
    }
}

剩下的问题就是如何提供 AnimatedVisibilityScope ,我们可以使用 AnimatedContent composable 来解决这个问题,并且 Voyager 的 Navigator 可以自定义 content,那么我们可以在自定义 content 内加上 AnimatedContent

@Composable
fun CurrentAnimatedScreen(navigator: Navigator) {
    val currentScreen = navigator.lastItem
    AnimatedContent(
        targetState = currentScreen.key,
    ) { targetScreenKey ->
        CompositionLocalProvider(
            LocalAnimatedVisibilityScope provides this
        ) {
            val targetScreen = navigator.items.lastOrNull { it.key == targetScreenKey }
            if (targetScreen != null) {
                navigator.saveableState("currentScreen", screen = targetScreen) {
                    targetScreen.Content()
                }
            }
        }
    }
}

这样我们就可以在给任意一个 composable 加上 sharedElement transitions 了。

当然,为了方便使用,我们还可以提供一个这样的扩展函数。

@Composable
fun Modifier.sharedBoundsBetweenScreen(key: String): Modifier {
    val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
    val sharedTransitionScope = LocalSharedTransitionScope.current
    if (animatedVisibilityScope == null || sharedTransitionScope == null) return this
    return with(sharedTransitionScope) {
        sharedBounds(
            sharedContentState = rememberSharedContentState(key),
            animatedVisibilityScope = animatedVisibilityScope,
        )
    }
}

不过目前还发现了一个问题,在进入新的页面时动画是正常的,但是推出动画却不起作用,目测是 Voyager 本身 pop screen 的机制导致的,composition 没有捕获到退出前的那个 element,因此无法完成动画,这个问题不太容易解决,我试几个方案都不是很好。

The post 在 Voyager 中使用 SharedElement 共享元素动画 first appeared on 张可的博客.