MoreRSS

site iconZhangKe | 张可修改

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

Inoreader Feedly Follow Feedbin Local Reader

ZhangKe | 张可的 RSS 预览

梦中的两次濒死体验

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 张可的博客.

一个KMP/CMP项目的组织结构和集成方式

2025-07-15 21:29:25

本文主要介绍一下 KMP/CMP 项目的组织结构和集成方式,从而概览整个技术架构,对其有个总体上的认知。

对于如何创建 KMP 项目可以直接使用官方的工具:

https://kmp.jetbrains.com

具体的调试步骤和环境可以看官方文档:https://www.jetbrains.com/help/kotlin-multiplatform-dev/quickstart.html

官方的项目结构文档:https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-discover-project.html

KMP/CMP 参考项目 Fread:https://github.com/0xZhangKe/Fread

基本结构

首先 KMP 项目目前仍然使用 Gradle 构建,因此项目结构和我们的 Android 项目大体上类似,但是源码文件夹有所不同,毕竟是跨平台项目,肯定存在一些代码是各端独自实现的情况,那么肯定也需要不同的平台文件夹存放该平台特有的代码及实现。

所以一个 KMP 项目的模块结构如下:

目标平台有 Android 和 iOS,commonMain 则表示所有平台的共享代码。

commonCode

上图中的 commonMain 中存放的就是通用代码,这里的代码可以被所有平台使用,也会参与所有平台的构建流程。

其中的代码与平台无关,也不可以使用任何平台独有的 Api,只能使用其它模块或者支持 KMP 的依赖库中的 common code。当然目前也是好起来了,越来越多的三方库都在已经支持了 KMP,大部分情况下都有现成的库直接用。

对于不同的编译目标平台,commonMain 中的代码也会编译成不同的产物,这一切都是 Kotlin 编译器的功劳。

Targets

目标平台定义了 common code 将会被编译到哪些平台。对于不同的平台编译的产物也不同,比如 androidTarget 产物是 aar,iOS 产物是 framework

另外 Target 本身只是一个标识符,用于标识不同的目标平台,编译器将会根据这些目标平台的不同来编译出不同的产物。包括依赖包管理也是通过 Target 标识的。

我们可以通过在 gradle 的 kotlin 块内管理 target:

kotlin {
    jvm() // Declares a JVM target
    iosArm64() // Declares a target that corresponds to 64-bit iPhones
}

通过声明 jvmiosArm64目标,commonMain 中的代码将被编译为这些目标:

Target 还提供了 Gradle DSL 用于一些配置,其中包括通用配置和平台独有的配置。

通用配置

通用配置是指在任何 target 内都可以使用的配置。

name Description
platformType 目标平台类型,取值范围:jvmandroidJvmjswasmnativecommon
artifactsTaskName 构建此目标的最终产物的任务名称
components 用于设置 Gradle 发布内容的组件
compilerOptions 用于该目标的编译器选项,此声明会覆盖在顶层配置的任何 compilerOptions {} 设置。

此外还有一些平台独有的配置。

kotlin {
    wasmWasi {
        nodejs()
        binaries.executable()
    }
    js().browser {
        webpackTask { /* ... */ }
    }
    linuxX64 {
        binaries {
            executable {
                // Binary configuration.
            }
        }
    }
    androidTarget {
        publishLibraryVariants("release")
    }
}

具体配置可以看官方文档:https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-dsl-reference.html

Source sets

Kotlin Source set一组具有各自 Target、依赖项和编译器选项的源文件。它是在 KMP 项目中共享代码的主要方式。

Source Set 的特性:

  • 对于给定的项目具有唯一的名称。
  • 包含一组源文件和资源,通常存储在与源集同名的目录中。
  • 指定此 Source set 中的代码编译为的一组目标。这些目标会影响此源集中可用的语言结构和依赖项。
  • 定义其自己的依赖项和编译器选项。

Kotlin 提供了一系列预定义的源集。其中之一是 commonMain,它存在于所有 KMP 项目中,并编译为所有声明的目标。

在 Gradle 脚本中,我们可以通过 kotlin.sourceSets {} 块内的名称访问 Source set:

kotlin {
    sourceSets {
        commonMain {
            // Configure the commonMain source set
        }
    }
}

这里的 commonMain 对应的就是我们上面第一张截图中的那个 commonMain 文件夹,一般来说每个常规的 source set 都会有一个自己的代码文件夹,不同模块中的相同的 source set 文件夹中的代码可以互相调用,因为他们最终都会被编译到同一个产物内。

因此 source set 主要是用来管理该 Target 下的代码集合的,比如为该 Target 添加某些依赖库,设置源码路径等。

Platform-specific source sets

跟 Target 类似的是,source set 也提供了针对平台特有的设置。

在设置依赖库的时候我们经常会用到这个功能,比如我们现在需要一个视频播放器,但是没有找到适合业务的跨平台播放器组件,以此需要各端独自实现,那么我们在 Android 端需要依赖 media3,iOS 端需要依赖另外一个视频播放器的组件,那么就可以通过 source set 设置不同的依赖。

kotlin {
    sourceSets {
        androidMain {
            dependencies {
	            implementation(libs.media3)
            }
        }
        iosMain {
            dependencies {
                implementation(libs.ios.video.player)
            }
        }
    }
}

通过这样的方式,我们就可以针对不同的平台添加不同的依赖,这些依赖可以在各自的文件夹中依赖到,比如我们可以在 androidMain 文件夹中直接使用 media3,在 iosMain 中使用 iosVideoPlayer

Compilation to a specific target

在编译阶段,编译器会针对当前编译的 Target 选择使用不同的 source set,比如编译到 Android 平台,那么会选择 commonMain+androidMain 的代码和依赖库进行编译。

Intermediate source sets(中间源集)

除了上述我们说到的普通 source set 之外,还有一种叫做中间源集。

中间源集的概念其实很简单,比如我们现在的 KMP 项目同时支持了 Android、 iOS 和 macOS,现在我们需要一个用于生成 UUID 的函数,这个函数各端各自实现。但实际上 iOS 和 macOS 的实现是一致的,我们总不能同样的代码在 iosMainmacosMain 中各写一份吧,这样很容易出错。

于是 KMP 提供了中间源集用于解决这个问题,在上述情况中,我们可以直接将代码写在 appleMain 中,appleMain 可以使用 Apple 平台的能力,并且在 iosMainmacosMain 中都可以依赖到其中的代码。

完整的层次结构如下,其中彩色的部分可以理解为 Intermediate source sets.

测试

从上面的示例和截图中可以看出来,source set 源码文件夹都带了一个 Main/Test 后缀,Main 包含生产代码,Test 后缀就表示该 source set 的测试代码,他们之间会自动建立关联,Test 可以直接依赖到 Main 中的代码。

Kotlin 也提供了支持 KMP 的默认测试框架,其中包含@kotlin.Test注解以及各种断言方法,例如assertEqualsassertTrue

当然我们也可以像常规测试一样,为每个平台在其各自的 source set 中编写平台特定的测试。也可以为每个 source set 设置平台特定的依赖项,例如 JUnit 针对 JVM 和 XCTest iOS 的依赖项。要针对特定目标运行测试,请使用<targetName>Test任务。

依赖关系-dependsOn

dependsOn 是两个 source sets 之间的特定关系,通过 dependsOn 将整个 source sets 关系设置为一个树状结构,不过一般来说我们并不需要手动设置。

kotlin {
    // Targets declaration
    sourceSets {
        // Example of configuring the dependsOn relation
        iosArm64Main.dependsOn(commonMain)
    }
}

自定义 source sets

在某些情况下,我们可能需要在项目中设置自定义中间源集。假设有一个项目会编译到 JVM、JS 和 Linux,并且只想在 JVM 和 JS 之间共享一些源。在这种情况下,我们应该为这对目标找到一个特定的源集。

Kotlin 不会自动创建这样的源集,但我们可以使用 by creating 手动创建它:

kotlin {
    jvm()
    js()
    linuxX64()

    sourceSets {
        // Create a source set named "jvmAndJs"
        val jvmAndJsMain by creating {
            // …
        }
    }
}

但此时我们自己创建的 jvmAndJsMain 的依赖关系是独立的,因为我们并未手动指定任何依赖关系,此时就需要使用上面说的 dependsOn 了。

kotlin {
    jvm()
    js()
    linuxX64()

    sourceSets {
        val jvmAndJsMain by creating {
            // Don't forget to add dependsOn to commonMain
            dependsOn(commonMain.get())
        }

        jvmMain {
            dependsOn(jvmAndJsMain)
        }

        jsMain {
            dependsOn(jvmAndJsMain)
        }
    }
}

现在,该项目的依赖关系如下:

依赖库的 KMP 兼容性

首先,我们可以通过如下方式给不同的 source set 添加不同的依赖库:

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
        }
        jvmMain.dependencies {
            implementation("com.google.guava:guava:32.1.2-jre")
        }
    }
}

被添加到 commonMain 中的依赖库会被传递到所有 Target 中,而添加到 jvmMain 中的依赖库只会在 jvmMain 文件夹中可见。

Kotlin 会把每个依赖关系解析为一组 source sets,而这组 source sets 中的每一个都必须和当前使用它的源集(consumer source set)在目标平台上兼容。

也就是说你添加的依赖库的 target 列表必须包含你的项目的 target 列表,缺少一个都会被视为不兼容。

举例来说,如果你当前的项目 target 中包含了 jsMain,但是你打算添加的依赖库的 target 中未包含 jsMain,那么就会被视为不兼容,那么就无法使用这个库。

对齐不同 source sets 依赖库版本

Kotlin Multiplatform 的 common 源集(commonMain)会被编译多次(为不同 Target),因此依赖库版本必须一致。

为什么要对齐版本(align versions)?

  • commonMain 会参与多个目标(如 Android、iOS 等)的构建。
  • 如果在不同目标中,commonMain 使用的依赖版本不一致,会导致编译出来的 .klib 不一致,从而出问题。
  • Kotlin Gradle 插件会自动统一这些版本,确保所有使用 commonMain 的地方依赖的库版本相同

举个例子:

首先 commonMain 中声明了如下依赖:

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")

同时 androidMain 中又声明了如下依赖:

implementation("androidx.navigation:navigation-compose:2.7.7")

但是这个 navigation 库内部依赖了 coroutines 1.8.0

那么问题来了:

  • androidMaincommonMain 是要一起编译的
  • Gradle 会发现冲突(1.7.3 vs 1.8.0),它会选择更高的版本:1.8.0
  • 所以 commonMain 也会用 1.8.0 的 coroutines
  • iOS 同样也使用了 commonMain 的代码
  • 所以 Gradle 会自动把 coroutines 1.8.0 应用到 iosMain 等所有平台

Kotlin Multiplatform 项目中,只要有任何 source set 引入了较高版本的共享依赖,Gradle 就会自动将这个版本同步到所有需要它的 source set 上,以确保生成的 common 代码在所有平台上都是一致的。

iOS 集成

对于 iOS 平台来说,KMP 项目的代码会直接编译成 framework 产物给到 iOS 项目集成继续参与 Xcode 的编译流程。那么具体来说是如何集成的呢?比如我们直接 clone 一个 KMP/CMP 项目到本地然后直接用 Xcode 打开可以直接运行,那么 Kotlin 代码如何开始编译?

Xcode 构建阶段(Build Phase)可以添加一些自定义脚本,对于一个默认支持了 KMP 的 Xcode 项目来说,构建阶段会被添加一行自定义脚本,该脚本用于执行一个 gradle 命令以此生成 Xcode 需要的 framework 文件。

我们可以打开 Xcode 项目视图中的 Build Phase 看到这个脚本:

其实我们也可以自己运行这个脚本,运行完成后会看到 build 文件夹下生成了 framework 文件。

好了现在 frameworks 已经生成了,剩下的就是如何把这这个文件添加到 Xcode 项目的依赖中去。

我们接着打开 Xcode 的项目视图中的 Build Settings 往下找到 Search Paths 栏,可以看到 gradle build 目录中的 frameworks 路径已经被添加到项目中了。

这样整个 Xcode 的构建环节就搞明白了,首先通过自定义编译脚本调用 gradle 编译出 framework 文件,并且预先将该路径添加到了 Xcode 构建依赖中,然后 Xcode 内就可以把 KMP 模块当作一个普通的 framework 依赖库使用了。

当然这些配置也可以根据自己的项目需要修改,具体的配置也可以在 project.pbxproj 文件内看到。

现有项目集成 KMP/CMP

首选还是直接看官方文档:https://developer.android.com/kotlin/multiplatform/migrate

因为 Kotlin for iOS 代码最终会编译成 framework,所以如果现有的 Xcode 项目如果想集成 KMP 其实也可以不必设置编译脚本,也不必对现有项目做其他的更改,只需要像依赖普通的库一样依赖这个 Kotlin 的产物即可。

The post 一个KMP/CMP项目的组织结构和集成方式 first appeared on 张可的博客.

历时两年半开发,Fread 项目现在决定开源

2025-04-27 13:08:14

大家好,Fread 项目开发到今天已经有两年半了,上线也已经八个月了,目前项目趋于稳定,现在决定使用 Apache 2.0 协议将其开源。

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

首先介绍下 Fread 的技术栈。

Fread 是一个使用 KMP(Kotlin Multiplatform) 和 CMP(Compose Multiplatform) 的移动客户端应用,所以编程语言使用的是 Kotlin,技术栈也都是基于 Kotlin 的,主要如下。

上面列举了一些具有代表性的依赖库,除此之外还有很多其他的库没有列举出来,从这些具有代表性的库可以大概了解到 Fread 的技术栈。

Fread 是一个去中心化的联邦宇宙 Micro blogging 社交客户端,目前已经支持了 Mastodon、Bluesky、RSS 三种社交平台协议,这意味着你可以在同一个 App 中同时使用这三种社交平台,Fread 不仅提供了 Micro blogging 社交的一致性,也保持了不同平台的特色功能。 更重要的是,Fread 支持创建一个同时包含了三种来自不同平台的 Feeds 流,这打破了协议之间的壁垒,进一步增强了去中心化的能力,另外 Fread 也专注于提供漂亮舒适的 UI/UX。

Fread 之所以现在决定开源,一方面是刚开始没考虑好到底是付费下载还是免费试用,但是上线一周后就直接改成了免费下载,但是之前因为是闭源所以仓库中有一些敏感的数据信息,担心开源后会泄漏出去,现在已经解决了这个问题。另外我对 Fread 有不少设想和规划,开源后需要面临其他人提交 PR 的情况,这可能会打乱开发节奏,所以在最近支持了 Bluesky 之后,项目也稳定下来,才开始着手开源的事情。

目前 Fread 虽然使用了 KMP 跨平台,iOS 也能运行起来,但是只上架了 Android 版,iOS 还有一些适配工作没完成,未来适配完成后会上架 App Store。

之前为了监控线上 Crash 情况和用户量所以接入了 Firebase analytics,后来又因为消息推送接入了 FCM,并且为了中转 FCM 消息我自己搭建了一台中继云服务,这里面因为涉及到我自己的 Firebase 账户和云服务信息,所以这部分的内容从 Fread 仓库中移除了,这个在我的私有仓库,可以作为 Fread 子模块进行编译,并且设置了可选编译,意思是指通过 Fread 主仓也可以编译,功能没有什么变化,只是会缺少 Firebase 的能力,其他没有任何影响,但是我这里可以编译出带有 Firbase 能力的包并且上架 Google Play,未来在 F-droid 上架的版本也会是不包含 Firebase 的版本。

关于 Fread 更多的技术细节大家可以直接查看代码,未来我可能会继续发布一些博客介绍 Fread 中的一些设计和细节。

https://play.google.com/store/apps/details?id=com.zhangke.fread

The post 历时两年半开发,Fread 项目现在决定开源 first appeared on 张可的博客.