广州的五月,空气里已经吸饱了水分。宿舍的空调发出轻微的嗡嗡声,室友们大多已经睡了,只有我桌上的屏幕还亮着。Android Studio 的右下角,Gradle Build Finished 的提示悄然弹出,耗时 2m 14s。
我长舒了一口气,把杯子里剩下的半口凉水喝完,然后点下了 Push。
看着绿色的进度条在 GitHub 的页面上走完,我的第一个用 Kotlin 和 Jetpack Compose 完整构建的 Android 项目,算是正式告一段落了。我给它起名叫「生命万灵的合集」。
名字听起来有些大,但其实它的初衷很小,只是一个用来探索、学习和分享自然之美的平台。
为什么是动植物?
做这个东西的想法,其实已经在脑子里盘旋很久了。
广商的校园不算特别大,但绿化做得很好。从宿舍去机房的路上,会经过几排高大的细叶榕,有时还能碰见几只在草丛里打滚的三花猫。我是一个走路很慢的人,尤其是在大四这最后一年,没有了繁重的课业压力,保研和工作的去向也都尘埃落定之后,我花了很多时间在校园里瞎晃。
我会停下来看一片叶子上的脉络,或者观察一只不知名的甲虫怎么翻过台阶。但在这些时刻,我总会感到一种匮乏——我不知道它们的名字。
我去应用商店搜过不少动植物科普或识别的 App。它们大多有着极其商业化的界面,开屏是五秒的广告,主页堆砌着各种签到、商城和乱七八糟的社交圈子。识别一次植物,还要先看一段视频广告。它们太吵了。自然本来是安静的,但这些工具却充满了互联网特有的焦躁感。
我只是想要一个干净的、纯粹的图鉴。一个像小时候在图书馆翻开的那种厚重的硬壳百科全书一样的应用。既然找不到,那就自己写一个。这大概是软件工程专业给我留下的最实用的习惯:遇到没有现成工具解决的需求,就自己动手。
抛弃 XML,拥抱 Compose
在决定做「生命万灵」之后,我做的第一个技术决策,就是彻底抛弃传统的 XML 布局,全盘使用 Jetpack Compose。
在此之前,我写过不少 Android 项目。传统的 View 体系里,写 UI 是一件很割裂的事情。你需要在一个 XML 文件里定义各种 RelativeLayout、LinearLayout,用冗长的代码去控制边距,然后再回到 Java 或 Kotlin 代码里,通过 findViewById 或者 ViewBinding 把它们捞出来,再给它们设置数据和点击事件。
每次在布局文件和逻辑代码之间来回切换的时候,我总觉得自己的思维被打断了。
Compose 完全不同。它是声明式的。当我第一次在一个 @Composable 注解的函数里,用几行代码写出一个带圆角和阴影的卡片时,那种流畅感是前所未有的。
在开发动植物百科浏览这个核心功能时,这种优势体现得淋漓尽致。百科列表需要展示大量的高清图片和文字简介。如果用以前的 RecyclerView,我得写 Adapter,写 ViewHolder,还要处理复杂的复用逻辑。而在 Compose 里,一个 LazyColumn 就解决了所有问题。
当然,阵痛期也是有的。声明式 UI 的核心在于状态(State)的驱动。刚开始写的时候,我经常因为状态提升(State Hoisting)做得不好,导致界面的某一部分没有按预期刷新,或者引发不必要的重组(Recomposition)。好几个深夜,我就对着 Layout Inspector 的重组次数发呆,一点点把嵌套在深处的业务逻辑抽离出来,放进 ViewModel 里,用 LiveData 去包裹它们,再在 Compose 里 observeAsState()。
当整个 MVVM 架构跑通,数据像水流一样从 Repository 层流向 ViewModel,再自然地驱动 UI 变化时,那种代码结构上的秩序感,让我觉得之前的折腾都是值得的。
设计与克制:像做标本一样做 UI
我是个写代码的,不懂专业的设计,但我知道自己想要什么样的感觉。
「生命万灵」的视觉风格定得很克制。我没有用 Android 默认的那套 Material Design 里高饱和度的蓝色或紫色。我从树叶和泥土里取色,主色调选了一种很深的森林绿,背景色则是带着一点点灰度的米白,就像是稍微泛黄的纸张。
在设计图鉴卡片的时候,我花了很多心思。每一种动植物,都应该得到尊重。我把图片的展示面积放得很大,用了 Glide 来处理图片的异步加载和缓存。为了避免图片加载出来之前的突兀感,我用 Compose 写了一个淡淡的呼吸灯效果作为占位符。
详情页的处理也是一样。名字、拉丁学名、科属分类,这些信息被我用不同的字重和颜色区分开,排布在图片下方。我还接入了 ExoPlayer,为部分收录了叫声的鸟类和昆虫增加了音频播放功能。当你在深夜的宿舍里,点开一张夜莺的卡片,屏幕里传出清晰的鸟鸣时,那种感觉很奇妙,仿佛这块冰冷的玻璃屏幕连接着某片未知的树林。
寻找数据的妥协与坚持
代码好写,但数据难寻。这是个人开发者永远的痛。
一开始,我想建一个庞大的本地数据库。但很快我发现,高质量的物种数据和图片版权是一个巨大的壁垒。我不可能自己去拍完世界上所有的植物。
最后,我选择了一个折中的方案。我找了一些开源的自然数据库 API,通过 Retrofit2 和 OkHttp3 去做网络请求。为了保证数据的稳定性,我在后端(也是我自己用 Spring Boot 写的)做了一层代理和缓存。
其实处理这些乱七八糟的 JSON 数据是一件很枯燥的事情。有些 API 返回的字段残缺不全,有些图片的 URL 已经失效。我不得不写了大量的错误处理逻辑。在 Compose 里,我为每一种网络状态(加载中、成功、失败、空数据)都设计了专门的 UI 状态。我不希望用户在遇到网络错误时,只看到一个干瘪的 Toast 提示,我希望哪怕是错误页面,也是有质感的。
拍照识别功能是这个 App 的另一个核心。我没有自己去训练模型——那超出了我的能力和机器的算力范围。我调用了国内一家大厂的 AI 识别 API。
但为了让拍照的体验更好,我放弃了调用系统相机,而是用 CameraX 自己实现了一套相机界面。CameraX 的 API 设计比早期的 Camera API 好太多了。我把取景框做成了全屏,只在底部留了一个半透明的拍摄按钮。
我还记得测试这个功能的那天下午。我拿着手机跑到宿舍楼下的花坛边,对准了一株开着红花的灌木按下了快门。
手机屏幕上出现了一个转动的加载圈。一秒,两秒。然后屏幕下方滑出一个卡片:
「朱槿(Hibiscus rosa-sinensis),锦葵科木槿属常绿灌木……」
那一刻,我站在广州五月闷热的午后,看着屏幕上的字,突然觉得很有成就感。不是因为我用了什么高深的技术,而是因为我通过自己敲下的代码,和这个真实的世界产生了一点具体的联系。
社区:让孤独的观察者相遇
最开始,我没打算做社区功能。我怕麻烦,怕内容审核,怕它变成另一个喧嚣的朋友圈。
但在开发接近尾声的时候,我把测试包发给了几个朋友。其中一个平时很少说话的室友,拿着 App 去学校的人工湖边转了一圈,回来后跟我抱怨:“我拍了一只很奇怪的水鸟,识别出来是夜鹭,但我没地方发啊。”
这句话触动了我。观察自然,很多时候是一件孤独的事情。但如果我们发现了一些美丽的、奇特的东西,分享的欲望是人类的本能。
于是我加了一个很轻量的图文动态模块。没有点赞,没有评论,只有时间流。你可以把你拍到的动植物,连同 AI 的识别结果一起发布上去。
后端我用得很简单,就是基础的增删改查。但在前端的列表展示上,我尽量让它看起来像一个影像日记。照片按比例裁剪,文字静静地躺在下面。
后来,那个室友在上面发了一张很糊的照片,是一只正在打哈欠的流浪猫。配文只有两个字:“困了。”
看着那条动态,我突然觉得这个 App 活过来了。它不再只是一个冷冰冰的科普工具,它承载了某个人在某个瞬间,对这个世界投去的一瞥。
尾声
现在,代码都已经静静地躺在 GitHub 的仓库里了。
前端:shenglingji-android 后端:shenglingji
大学四年,我拿过两次国奖,绩点维持在 4.02。在外人看来,这或许是一份很漂亮的履历。但那些分数和奖状,很多时候只是一种为了符合某种社会评价标准而做出的努力。它们是抽象的。
而「生命万灵的合集」是具体的。它是几万行 Kotlin 代码,是无数次 Gradle Sync,是深夜里为了调一个阴影参数而反复编译的枯燥过程,也是我试图去理解和记录这个世界的一次微小尝试。
合上电脑,走到阳台。广州的天已经快亮了,空气里的潮湿感更重了一些。楼下的榕树在微光里显得很深邃。我知道它叫细叶榕,我知道它的气生根会越长越长,我也知道,