MoreRSS

site iconZhangKe | 张可

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

Inoreader Feedly Follow Feedbin Local Reader

ZhangKe | 张可的 RSS 预览

一个大型 Android 项目 Fread 的模块划分哲学

2024-11-04 23:52:09

最近几天忙着搬家,代码几乎没怎么写,趁着周日晚上有点空来水一篇文章。

大概两年前从 B 站离职的时候就决定利用空余时间做 Fread 这个项目,现在项目迭代了这么久,也上架一段时间了,打算写点文章大概介绍下里面用到的一些技术和思路。

现在项目中大概有十几个模块,拆分模块的主要目的是为了降低未来的修改成本,同时模块的拆分也能反映出技术架构和业务架构

目前项目的模块关系图大概如下图所示。

上图中的所有同层级的模块都是平行模块,这意味着它们不会互相依赖,模块的依赖关系按照图中箭头的方向单向依赖。

理解业务

不同的软件有不同的业务,模块设计应该因地制宜,一个好的设计一定是需要先充分理解业务的。

如果两个模块在业务上就有依赖关系,那么一定要在软件架构上体现出来。一些原本就有耦合关系的业务但是在软件架构中却彻底分离,这会给未来带来无穷无尽的麻烦。

在理解业务的基础之上可以进行业务形式化建模,在对业务有了足够充分的认知之后再进行软件架构设计,业务架构和软件架构尽可能保持一致。

比如目前国内很多项目中都在使用的路由框架就承担了解除耦合的责任,架构中把一些看起来关系不大的模块做拆分,然后通过路由框架进行通信,实际上造成了业务边界和关系的混乱。因为通过路由跳转就意味着业务有关联,既然业务上有关联那么架构上也应该有所体现,原本可以简单的通过语法来约束和表达的事情最后却只能用 URI 来表达,约束校验只能推迟到运行时再做判断了。

一个解决办法是提供一个上图所示的 Biz Framework 模块和 Common Biz 模块。

Framework

Framework 模块是纯技术的、业务无关的、但根据业务需求编写的通用能力。

它不依赖任何业务模型,只依赖一些 Library,其中包含一些对第三方库的简单化工具,业务无关的基础能力以及各种类型的工具类。

Biz Framework

既然有了技术上的 Framework,那么有一个业务上的 Framework 也不过分吧。

对于一些足够通用,甚至可以作为项目基石的一些业务可以考虑放入这个模块。

由于这个模块是业务的最底层,必须足够抽象和基础,所以这里面大部分会是接口和数据模型。

比如作为一个 Microblogging 客户端,无论是哪个业务模块几乎都会使用到诸如 User、Blog 这样的模型,以及无论哪个模块,都会判断登录状态,发起登陆等,因此可以把它们定义在此处。

Common Biz

通用业务模块,一般来说,大部分的通用业务应该在此处,比如数据分析、通用 UI 组件、通用页面等。该模块负责解决一些通用的能力,可能会被任何一个上层模块依赖,同时也会依赖 Biz Framework 模块获取其中的数据类型等。

对于一些通用的业务工具类也可以放在此处,比如对 Blog 中时间的不同格式化方式、列表内容加载流程范式等。

甚至一些简单的业务也可以放在这里,因为 Features 模块包含的是比较大的业务,对于一些小到不值得划分模块的业务写到这里也可以接受。

Features

这个模块的职责就很清晰了,Features 下面的每个模块都仅包含一个独立的业务。比如上图中的 Feeds 模块就是 Feeds 相关的部分,Account 是账户管理部分等。

对于我的项目来说,我有四个 Features 模块,刚好对应首页底部的四个 TAB。

到了这里会有个问题,不同 Feature 之间几乎肯定是会有互相跳转的需求的,虽然业务比较独立,但这种需求也偶尔会出现,这里可以选择在 common biz 模块提供一个不同模块的 Visitor 接口,每个模块各自实现,然后通过这个 Visitor 来跳转。

如果对于一些更复杂的场景,以及包含了 DeepLink 等需求的场景,可以考虑使用路由,但是使用路由跳转应该谨慎一点,慎重考虑之后再做决定。

Plugins

Plugins 模块一般根据项目的情况决定需不需要,它作为插件化架构的插件层存在,这里的插件是指软件架构中的一种定义。

对于一些可能的动态功能,或者具体实现依赖于运行环境的功能,可以考虑放入此处。

插件层一般不需要被任何模块依赖,它与 Application 处于同一个层级(至少源码级别是这样的),编译时将他打入包内即可,可以通过依赖注入或者一些 SPI 机制获取其实现。

Application

这个模块就更简单了,主要用来组合所有的 Feature 模块,一般不会包含太多代码。

对于跨平台项目来说,可能存在多个 Application 模块,每一个对应一种平台。

上面就是我在项目中使用的模块划分方式,目前使用下来感觉很丝滑,没遇到什么坑,这也是演进了两年的结果,也就是我自己的项目能这么玩了,哪里看着不顺眼就来重构一下,也希望这对大家有所帮助,也欢迎大家下载 Fread 体验。

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

The post 一个大型 Android 项目 Fread 的模块划分哲学 first appeared on 张可.

独立博客自省问卷15题

2024-10-14 16:07:28

最近看到好几个博主都写了这个自省问卷,我也写个。

你的博客更新频率是多少 ?

很不稳定,最近一年差不多一个月 1-2 篇,但前几年因为比较忙断更过一年。

你的博客上次更新是什么时候 ?

半个月前,因为中秋假期终于有空看了几部电影,挨个写了个观后感。

你的博客文章是原创的吗?

是的,我写博客没有营收目标,所以不会搬运,但有时候看到好看的英文文章倒是会有种翻译的冲动,但每次都压制住了。

你觉得自己的文章对他人有帮助吗?

应该会有吧,我的大部分博客都是技术文章以及攻略文章,并且写这种类型文章的时候我的目标也是写给别人看,所以应该会有点帮助。

你上次换博客主题/程序是什么时候 ?

2024 年初,实际上是把博客从 GitHub Page 搬到了自建网站,主题肯定要换啊。

你上一次捣腾博客主题代码是什么时候?

也是 2024 年初,找了一圈也没找到合适的主题,现在的这个是在原有基础上做了一些改动,但我还是不满意,先这样吧。

你会对博客主题进行二次开发吗?

目前不会,只会做一点微调。

你多久打开自己博客自我陶醉一次?

很少,只有旅游类的偶尔打开看看照片风景。不知道为啥,看自己写的文字会有种奇怪的排斥心理,导致我很少看。

你近期对自己博客域名什么感受 ?

满意

你每天都会看网站的流量统计吗?

很少看,大部分时候看邮件统计数据的通知就行了。

你通过博客的广告赚到钱了吗?

赚到了 0 元。

你去浏览别人的博客/网站主要为什么?

国内独立博客虽然不多,但是很多博主写的内容都很有趣,其实技术类型的我看的很少,一方面专业不同,另外也是个人没有看博客学习的习惯。主要还是看非技术类型的文章,可以接触到很多好玩的东西。

看到别人分享了一篇文章,你打开第一反应是什么?

打开看看

你觉得博客哪方面更重要?

内容和主题,我喜欢简单的主题和丰富的内容。

近期通过写博客有哪些新收获?

收获到了一些博客

The post 独立博客自省问卷15题 first appeared on 张可.

最近看的几部经典电影

2024-09-22 21:48:03

最近因为在搞 KMP,没怎么加需求,所以稍微空了点,正好赶上中秋和台风,索性在家好好看电影吧。

美国精神病人

这部电影确切的说应该是美国精英精神病人,主线剧情是华尔街精英蝙蝠侠外表精致、生活规律、事业有成,但到了晚上内心的黑暗逐渐占据理智只有杀人才能满足精神需求。

导演讽刺的是美国上层精英的金钱至上,虚荣虚伪,内心空虚而且压抑的生活,在这种环境下人会表现一些怪异的行为,包括毫无意义的名片攀比,主角蝙蝠侠就是被异化到极端的人物,连砍人用的斧头像是奢侈品,最终失去控制。

作为华尔街的精英群体,他们需要时刻保持和精英匹配的行为,定制的高级服装,精致的名片,得体的谈吐,职业假笑,这些都需要刻意维持,这样才能赚到精英的财富。

但精分的是他们一边自发的维持这个群体的意志,一边又被这个群体压迫。无意识的享受这种毫无意义的行为,人们被自己的欲望囚禁,在欲望中迷失。

国内的中产似乎也有这样的趋势,近年来越来越多的人把自己比作牛吗,戴上工牌就好像驴戴上了拉磨套装,为了努力工作自费美式,节日礼物的攀比等等。

在一个自洽的系统中是不应该感受到矛盾的,如果有,那可能就是生病了。

猜火车2

猜火车1 我还是 2016 年看的,到现在都过去八年了,好多剧情都忘记了,所以一直到最近才看猜火车2.

距离第一集已经过去了 20 年,贝格比已经越狱,二十年前的精神小伙已经变成了精神大叔,敲诈勒索开妓院仍然是他们的主要业务,这还是很惊喜的。

第二集主线剧情要清晰一点,故事跟第一集关系虽然有但是不多,忘了也没关系。

除了剧情之外,这集的光影艺术很漂亮,色彩和光线搭配的特别协调,这就是电影艺术。

中央车站

中央车站这部电影是我 2016 年标记的想看,但到了今年我才真正去看,一直以为自己已经看过了呢。

没想到这还是个公路电影,其实剧情也是有点俗套,但好的电影就是能把这种俗套的剧情给拍的更好。也是关于互相救赎的故事,一个单身的退休大妈带着一个失去母亲的男孩踏上了寻找父亲的旅程。

剧情挺温馨的,结局也不错,电影拍的比较早,而且因为是公路电影,能看到很多巴西的风土人情和人们的精神面貌,还是很推荐的。

朝圣之路

朝圣之路是指从法国各地经由比利牛斯山通往西班牙北部城市圣地亚哥-德孔波斯特拉的道路,现在成了全球经典的徒步路线。

电影主要是关于因为价值观不同导致一对父子形同陌路,父亲是个事业有成的牙医,但儿子一心只想环游世界,最终因为暴风雨死在了朝圣之路上。

父亲在去朝圣之路接儿子尸体的时候决定按照儿子的意愿走一次这条路线,并且把他的儿子骨灰撒在沿途。旅途中遇到了很多不同的人,内心逐渐理解了儿子的行为,最终决定按照儿子的方式活下去。

其实我个人觉得剧情有点老套,看了开头就能猜到结局的故事,不过朝圣之路的风景确实不错,而且沿途有很多欧洲小城镇,文化风景也不错。

美国黑帮

上世纪七十年代的美国黑帮的故事,主角是个帮派老大的随身保镖和司机,老大死了之后他靠着跟着老大学到的能力成功上位,并且靠着在越南的美军朋友搞到了不少纯度特别高的毒品然后卖到美国。成为了纽约最大的毒枭。

中规中矩的黑帮片,不过不同的是主角最后入狱,但出狱后和调查他的警官成了朋友,不知道为啥电影结尾有个半个小时的彩蛋。

泳者之心

被这个翻译过来的电影的名字骗了,英文名叫 Yong Woman and the Sea,这个名字不是挺好的,所以一直以为是个什么积极向上正能量最后拿了奥运会冠军举国欢腾的正能量电影,就一直没看,但毕竟评分这么高,看了之后发现不是这么回事。

影片改编自历史上首位横渡英吉利海峡女泳者的真实传奇经历,本身的故事就挺传奇的,拍出来的效果也非常好,最后那段迷失了海里然后看到了岸边火把让我想到了独行月球,不过还是挺感动的。

人物原型是格特鲁德·埃德尔(Gertrude Ederle,1905年10月23日—2003年11月30日),美国女子游泳运动员。埃德尔在1926年8月6日游泳横渡英吉利海峡,是首位游过英吉利海峡的女子。她横渡海峡用时14小时39分钟,比此前由男子创造的纪录还要快两小时。

那个时代人们对女性带着天然的偏见,认为即使是从生物角度来说女性也完全比不了男人,埃德尔打破了这个偏见,所以算是极具历史意义的事件。

The post 最近看的几部经典电影 first appeared on 张可.

KRouter 1.0 发布,支持参数注入以及 KMP 跨平台。

2024-09-18 22:43:00

因为时间关系,KRouter 第一版本写的比较粗糙,只能说勉强能用,最近空了点重新设计了一下并且完善了使用方式,支持了 Kotlin 跨平台以及参数注入,实现方案也从 ServiceLoader 彻底替换成通过 KSP 收集路由信息,并且发布到了 maven 中央仓库。

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

作为一个 Router 框架不仅要有收集路由的能力,还要有将收集到的路由提供给路由框架的能力,这也是初版我用 ServiceLoader 的原因,所以这次新版本开发我刚开始打算用 KCP 修改字节码来实现这样的能力,结果研究了好几天发现还是不行,最终被卡在了 KCP 无法读写依赖模块的类文件问题上,目前我知道的是 KCP 只能读写当前模块的文件,依赖到的文件则不行,KSP 也是搞了一些骚操作做到的,并且相关文档也确实少得可怜,所以就放弃了,如果有人知道如何解决也可以告诉我下。

如何使用

使用方式仍然跟以前一样简单直接,首先给目标类添加注解:

@Destination("screen/main")
class MainScreen(
    @RouteParam("id") val id: String,
    @RouteParam("name") val name: String,
): Screen

然后使用 KRouter 获取对应的类:

val screen = KRouter.route<Screen>("screen/main?name=zhangke&id=123")

KRouter 目前提供了三个注解,分别是 @Destination@RouteUri@RouterParam.

@Destination

顾名思义,@Destination 注解表示路由的目标类,也就是目的地,接受一个参数作为路由地址。

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Destination(val route: String)

然后按照下面的方式使用:

@Destination("screen/profile/detail")
class ProfileDetailScreen: Screen

@RouterParam

@RouterParam 注解用于标识路由参数,被该注解标识的字段将会被自动注入参数。其中也包含一个参数表示被赋值字段对应路由中的 query 字段名,KRouter 会根据这个字段名去路由中解析出来并赋值。

@Target(AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class RouteParam(val name: String)

使用方式如下:

@Destination("screen/home/detail")
class HomeDetailScreen(
    @RouteParam("id") val id: String,
) : Screen {
}

除了构造器参数注入之外还支持属性字段注入。

@Destination("screen/home/detail")
class HomeDetailScreen(
    @RouteParam("id") val id: String,
) : Screen {

    @RouteParam("title") var title: String? = null
}

参数类型目前只支持基本类型和 String 类型,其他的暂不支持,对于希望传对象的需求可以转成 Json 字符串然后 encode 到路由中传递。

另外需要注意的是,由于 KSP 目前无法获取到参数以及属性的默认值,因此被注入的字段暂不支持设置默认值,这意味着如果你的被注入字段包含一个默认值,并且路由中不包含这个参数的话,那这个默认值将会失效。

@RouteUri

使用该注解注入的参数或属性将会被赋值为完整的路由,由于 @RouterParam 注解只能用来注入字段,对于一些解析过程复杂,参数类型复杂的场景,可以使用 @RouteUri 获取完整的路由。

KRouterModule

KRouterModule 是一个接口,用来实现具体的路由能力,可以通过 KRouter 类动态添加自定义的 Module,默认会先使用动态添加的 Module 进行路由,路由失败则使用 KRouter 内部的路由继续。

interface KRouterModule {
   fun route(uri: String): Any?
}

添加依赖

KRouter 提供了两个 KSP 插件。

  • krouter-collecting-compiler:用于收集路由信息,非主模块使用。
  • krouter-reducing-compiler:用于汇总各个模块路由信息,只有主工程模块(app模块)需要使用。
// 非主模块使用
ksp("io.github.0xzhangke:krouter-collecting-compiler:$latest_version")
// 主模块使用
ksp("io.github.0xzhangke:krouter-reducing-compiler:$latest_version")

另外还提供了一个注解模块和一个运行时模块。

  • krouter-runtime:运行时模块,提供了 KRouterKRouterModule 以及注解。
  • krouter-annotation:注解模块,只包含注解部分。
// 仅需要使用注解的模块使用
implementation("io.github.0xzhangke:krouter-runtime:$latest_version")
// 需要使用路由能力的模块使用
implementation("io.github.0xzhangke:krouter-annotation:$latest_version")

实现方案

首先,collecting-compiler 会收集所在模块所有的路由目标类信息,并生成一个所属于当前模块的KRouterModule ,生成的类大概如下:

public class RouterCollection_1726153189290() : KRouterModule {
    override fun route(uri: String): Any? {
        val routerUri = com.zhangke.krouter.internal.KRouterUri.create(uri)
        return when (routerUri.baseUrl) {
            "screen/home/detail" -> {
                com.zhangke.krouter.sample.home.HomeDetailScreen(
                    id = routerUri.requireQuery("id"),
                )
            }
            "screen/home/landing" -> {
                com.zhangke.krouter.sample.home.HomeLandingScreen(
										router = uri,
                )
            }
            else -> null
        }
    }
}

所有依赖了 collecting-compiler 插件的的模块都会生成一个这样的类。

然后在项目的主模块(一般是 app 模块)需要依赖 reducing-compiler 插件,这个插件的作用是用来生成一个固定包名和类名的类,并且找到所有 collecting-compiler 生成的类然后添加到该类中。

public class AutoReducingModule() : KRouterModule {

  private val moduleList: List<KRouterModule> = listOf<KRouterModule>(
          com.zhangke.krouter.generated.RouterCollection_1726153189283(),
          com.zhangke.krouter.generated.RouterCollection_1726153189290(),
          com.zhangke.krouter.generated.RouterCollection_1726153189284(),
          com.zhangke.krouter.generated.RouterCollection_1726153189709()
      )

  override fun route(uri: String): Any? = moduleList.firstNotNullOfOrNull { it.route(uri) }
}

KRouter 在运行时会通过反射创建这个类,然后路由过程委托给该类实现,这样就完成了整个路由的收集和实现过程。

The post KRouter 1.0 发布,支持参数注入以及 KMP 跨平台。 first appeared on 张可.

开发600多天的长毛象+RSS客户端Fread 终于上线啦!!!

2024-08-13 10:12:34

大家好,经过六百多天的开发,Fread 客户端终于上线啦!!!欢迎大家下载使用,本文介绍下 Fread 的使用方法以及产品设计理念和未来计划。

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

首先,Fread 目前是一个完备的长毛象(Mastodon)RSS 客户端,支持了几乎所有的长毛象功能,可以当作长毛象客户端使用,也可以当作 RSS 客户端使用,同样也可以创建一个同时包含了长毛象某用户以及 RSS 内容的混合 Feeds 流

另外,Fread 被设计为是一个去中心化的 MicroBlog 客户端,未来会尽可能兼容更多的协议,例如 Bluesky 等等,兼容长毛象和 RSS 只是第一步,我希望做一个我心目中的 MicroBlog 客户端的最终形态,我会按照这个方向来开发。

虽然现在社交产品这么多,但其实他们的类型主要也就这几种:即时聊天(WhatsApp)、博客、MicroBlog(Twitter、Mastodon)、论坛(Reddit)、用户自制视频等。这几种几乎包含了当今所有社交平台的形态,这其中内容传播最快、最丰富的应该就是 MicroBlog 了。

即时聊天和论坛虽然用户量巨大,消费时长很长,但用户与用户之间需要先建立好友关系才能沟通,信息很难做到大规模传播。短视频内容更偏向娱乐,而且由于其魔鬼推荐机制轻松消耗用户几个小时时间逐渐被人排斥。

MicroBlog 虽然被理解为短的博客,但实际上他的类型已经越来越丰富,文字、图片、视频、投票等,有些平台甚至可以当作短视频刷,平台也会扶持一些大 V 来输更多优质的内容吸引用户。

作为普通人也可以轻易的发布任意自己喜欢发布的内容,不管是一段简单的文字,还是几张漂亮的图片,或者一段有趣的视频,都可以发布到这些平台上。既可以在上面和朋友互现关注了解近况,也可以关注大 V,这承担了非常多的用户消费需要。

当然现在进入了 Web3 时代,大家对社交和有了更多的要求,其中个人隐私和自主掌控个人数据是主要要求,分布式天然可以解决这个问题,所以把分布式概念引入社交领域几乎是社会发展的必然阶段。这个阶段出现了一些类似长毛象和 Bluesky 的协议,并且已经吸引了相当多的用户。

长毛象不仅是分布式的,它的内容也是“分布式的”,因为没有了推荐机制,所以用户之间也需要先建立连接才能沟通,当然也提供了全站时间线和热榜,但其内容和形式比较简单。分布式平台也有用户需要中心化的内容,如何筛选和获取到更优质的内容就成了长毛象的“问题”。长毛象内容的扩散几乎完全依赖用户的关注、时间线和趋势榜了,这其实会导致以内容为生的创作者缺乏更新动力。

国内有一些我非常喜欢的博主在国内很多平台都有账号,我有时候会想跟他们沟通去长毛象也开个号吧,但实在不知道该如何劝说。

我为什么要开发 Fread

其实作为用户来说,并不需要这么多协议,尤其是普通用户也不具备很多相关的专业知识,这些社交平台的使用门槛应该不能比 X 高多少,并且使用体验也应该更好一点才行,毕竟这是“下一代产品”。

只不过目前并没有统一的社交协议,这并不多的用户被分割在了多个平台,但不管协议怎么发展,有多少不同的协议,我希望至少用户可以在同一个客户端内解决这个问题,不需要再来回切换 App 去看短博客了,并且我觉得用户应该有更好的用户体验,有着更低的使用门槛,新手不需要去了解太多协议内容,开箱即用,这就是我开发 Fread 的初衷。

现在 Fread 虽然已经经历了六百多天的开发,但仍然不是很完善,距离我心中的完美的产品还差很多,在未来相当长的一段时间内我仍然会持续更新,我还会继续兼容更多的短博客类型的协议。

我之前简单用过 Misskey,他们的 UI 设计的真的非常漂亮,功能也很多,我后面的方向除了兼容更多的协议之外,还会兼容更多的短博客的交互,尽可能做到所有协议+所有 App 交互的全集。

Fread 中的几个基本概念

Fread 中的“内容”包含两种不同类型的内容:长毛象混合内容

长毛象内容通过添加长毛象实例获取,一个长毛象内容包含多个长毛象 Timeline 的 TAB,比如 Home、Local Timeline、Public Timeline、Trending 以及用户自己创建的列表。用户可以去长毛象内容设置页面编辑这些 TAB 的排序和显示/隐藏。

混合内容包含两种类型的信息源:长毛象用户 Timeline 和 RSS 订阅源。如果要添加长毛象用户 Timeline 那么可以直接复制改用户的主页地址粘贴到搜索框,Fread 会自动解析其用户信息。

Fread 使用介绍

首先是添加内容,进入添加内容页面后可以直接选择实例或者输入长毛象的实例名称或者地址直接添加长毛象内容,默认出现的服务器列表是存储在一个本地的 Json 文件中,这并不全,如果搜索不到请直接复制实例的地址到该搜索框搜索。

除了长毛象实例之外之外,你也可以输入某个用户的地址或者任意 RSS 地址然后添加,这个时候添加的就是自己创建的混合 Feeds,混合 Feeds 目前支持同时添加 RSS 以及长毛象某个用户的 Timeline,Fread 会将他们混合在一起然后按照时间排序生成一个 Feeds。

这个页面右上角的导入功能可以导入 OPML 文件,这是 RSS 订阅源集合文件,很多 RSS 阅读器都有到处 OPML 的功能。

除了自动加载下一页,当你滑动快到顶部时 Fread 会自动加载上一页内容,因此你可以往上滑动阅读更新的内容,你也可以双击回到顶部,然后下拉刷新获取最新的内容(我发现好多人还不知道顶部可以双击)。

Fread 对于多账号的支持是自动匹配实例的,不需要手动来回切换账号,如果你在首页添加了多个不同的实例的内容,那么每个实例都会自动对应改实例登录的账号。

由于 Fread 是一个短博客客户端,所以在 Feeds 流中,对于 RSS 内容也会尽可能按照短博客类型来排版,但是进入详情页面仍然可以获得很好的长文阅读体验。

好了,以上就是 Fread 的使用介绍,欢迎大家下载使用,也欢迎大家提出建议:

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

The post 开发600多天的长毛象+RSS客户端Fread 终于上线啦!!! first appeared on 张可.