Android Weekly Notes #481

Android Weekly Issue #481

Clean Code with Kotlin

如何衡量代码质量?
一个非官方的方法是wtfs/min.

利用Kotlin可以帮我们写出更clean的代码. 本文谈到的方面:

  • 有意义的名字.
  • 可以更多使用immutability.
  • 方法.
  • high cohesion and loosed coupling. 一些软件设计的原则.
  • 测试.
  • 注释.
  • code review.

Build Function Chains Using Composition in Kotlin

Compose的Modifier让我们可以通过连接方法的方式无限叠加效果:

// f(x) -> g(x) -> h(x) 
Modifier.width(10.dp).height(10.dp).padding(start = 8.dp).background(color = Color(0xFFFFF0C4))

方法链接和聚合

写一个普通的类如何达到这种效果呢?
一个简单的想法可能是返回这个对象:


fun changeOwner(newName: String) : Car {
    this.ownerName = newName
    return this
}

fun repaint(newColor: String) : Car {
    this.color = newColor
    return this
}

这种虽然管用, 但是不支持多种类型, 也不直观.

Modifier是咋做的呢, 一个例子:

fun Modifier.fillMaxWidth(fraction: Float = 1f) : Modifier

这是一个扩展方法.

因为Modifier是一个接口, 所以它支持了多种类型.

Modifier系统还使用了aggregation来聚合, 使得chaining能够发生.

Kotlin的fold()允许我们聚合操作, 在所有动作都执行完成后收集结果.

fold的用法:

// starts folding with initial value 0
// aggregates operation from left to right 

val numbers = listOf(1,2,3,4,5)
numbers.fold(0) { total, number -> total + number}

fold是有方向的:

val numbers = listOf(1,2,3,4,5)

// 1 + 2 -> 3 + 3 -> 6 + 4 -> 10 + 5 = 15
numbers.fold(0) { total, number -> total + number}

// 5 + 4 -> 9 + 3 -> 12 + 2 -> 14 + 1 = 15
numbers.foldRight(0) {total, number -> total + number}

Compose UI modifiers的本质

compose modifiers有四个必要的组成部分:

  • Modifier接口
  • Modifier元素
  • Modifier Companion
  • Combined Modifier

然后作者用这个同样的pattern写了car的例子:
https://gist.github.com/PatilSiddhesh/a5f415907aca8eb4f971238533bf2cf1

Using AdMob banner Ads in a Compose Layout

Google AdMob: https://developers.google.com/admob/android/banner?hl=en-GB

本文讲了如何把它嵌在Compose的UI中.

Jetpack Compose Animations Beyond the State Change

这个loading库:
https://github.com/HarlonWang/AVLoadingIndicatorView

作者试图实现Compose版本的.
然后遇到了一些问题, 主要是Compose的动画方式和以前不同, 需要思维转变.

这里还有一个animation的代码库:
https://github.com/touchlab-lab/compose-animations

Kotlin’s Sealed Interfaces & The Hole in The Sealing

sealed interface是kotlin 1.5推出的.

举例, 最原始的代码, 一个callback, 两个参数:

object SuccessfulJourneyCertificate
object JourneyFailed

fun onJourneyFinished(
    callback: (
         certificate: SuccessfulJourneyCertificate?,
         failure: JourneyFailed?
    ) -> Unit
) {
    // Save callback until journey has finished
}

成功和失败在同一个回调, 靠判断null来判断结果.

那么问题来了: 如果同时不为空或者同时为空, 代表什么意思呢?

解决方案1: 提供两个callback方法, 但是会带来重复代码.

解决方案2: 加一个sealed class JourneyResult, 还是用同一个回调方法.

但是如果我们的情况比较多, 比如有5种成功的情况和4种失败的情况, 我们就会有9种case.

Enum和sealed的区别:

  • sealed可以为不同类型定义方法.
  • sealed更自由, 每种类型可以有不同的参数.

有了sealed class, 为什么要有sealed interface呢?

  • 为了克服单继承的限制.
  • 不同点1: 实现sealed interface的类不需要再在同一个文件中, 而是在同一个包中即可. 所以如果lint检查有行数限制, 可以采用这种办法.
  • 不同点2: 枚举可以实现sealed interface.

比如:

sealed interface Direction

enum class HorizontalDirection : Direction {
    Left, Right
}

enum class VerticalDirection : Direction {
    Up, Down
}

什么时候sealed interface不是一个好主意呢?
一个不太好的例子:

sealed interface TrafficLightColor
sealed interface CarColor

sealed class Color {
    object Red:    Color(), TrafficLightColor, CarColor
    object Blue:   Color(), CarColor
    object Yellow: Color(), TrafficLightColor
    object Black:  Color(), CarColor
    object Green:  Color(), TrafficLightColor
    // ...
}

为什么不好呢?
违反了开闭原则, 我们修改了Color类的实现, 我们的Color类不应该知道颜色被用于交通灯还是汽车颜色.

这样很快就会失控.
每次我们要引入sealed interface的时候, 都要问自己, 新引入的这个接口, 是同等或更高层的抽象吗.

对于Traffic light更好的解决方案可能是这样:

enum class TrafficLightColor(
    val colorValue: Color
) {
    Red(Color.Red),
    Yellow(Color.Yellow),
    Green(Color.Green)
}

这样我们就不需要修改原来的Color模块, 而是在其外面扩展功能, 就符合了开闭原则.

Kotlin delegated property for Datastore Preferences library

之前读shared preferences然后转成flow的代码:

//Listen app theme mode (dark, light)
private val selectedThemeChannel: ConflatedBroadcastChannel<String> by lazy {
    ConflatedBroadcastChannel<String>().also { channel ->
        channel.trySend(selectedTheme)
    }
}

private val changeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
    when (key) {
        PREF_DARK_MODE_ENABLED -> selectedThemeChannel.trySend(selectedTheme)
    }
}

val selectedThemeFlow: Flow<String>
    get() = selectedThemeChannel.asFlow()

这个解决方案:

  • 引入了一些中间类型.
  • ConflatedBroadcastChannel这个类已经废弃了, 应该用StateFlow.

迁移到data store之后变成了这样:

//initialization with extension
private val dataStore: DataStore<Preferences> = context.dataStore

val selectedThemeFlow = dataStore.data
    .map { it[stringPreferencesKey(name = "pref_dark_mode")] }

这段代码:

enum class Theme(val storageKey: String) {
    LIGHT("light"),
    DARK("dark"),
    SYSTEM("system")
}

private const val PREF_DARK_MODE = "pref_dark_mode"

private val prefs: SharedPreferences = context.getSharedPreferences("PREFERENCES_NAME", Context.MODE_PRIVATE)

var theme: String
    get() = prefs.getString(PREF_DARK_MODE, SYSTEM.storageKey) ?: SYSTEM.storageKey
    set(value) {
        prefs.edit {
            putString(PREF_DARK_MODE, value)
        }
    }

可以用delegate property改成:

class StringPreference(
    private val preferences: SharedPreferences,
    private val name: String,
    private val defaultValue: String
) : ReadWriteProperty<Any, String?> {

    @WorkerThread
    override fun getValue(thisRef: Any, property: KProperty<*>) =
        preferences.getString(name, defaultValue) ?: defaultValue

    override fun setValue(thisRef: Any, property: KProperty<*>, value: String?) {
        preferences.edit {
            putString(name, value)
        }
    }
}

使用的时候:

var theme by StringPreference(
    preferences = prefs,
    name = "pref_dark_mode",
    defaultValue = SYSTEM.storageKey
)

Data Store的API没有提供读单个值的方法, 所有都是通过flow.

val EXAMPLE_COUNTER = intPreferencesKey("example_counter")

val exampleCounterFlow: Flow<Int> = context.dataStore.data
  .map { preferences ->
    // No type safety.
    preferences[EXAMPLE_COUNTER] ?: 0
}

文章用了first终结操作符:

The terminal operator that returns the first element emitted by the flow and then cancels flow’s collection. Throws NoSuchElementException if the flow was empty.

所以写了拓展方法:

fun <T> DataStore<Preferences>.get(
    key: Preferences.Key<T>,
    defaultValue: T
): T = runBlocking {
    data.first()[key] ?: defaultValue
}

fun <T> DataStore<Preferences>.set(
    key: Preferences.Key<T>,
    value: T?
) = runBlocking<Unit> {
    edit {
        if (value == null) {
            it.remove(key)
        } else {
            it[key] = value
        }
    }
}

然后替换进原来的delegates里:

class PreferenceDataStore<T>(
    private val dataStore: DataStore<Preferences>,
    private val key: Preferences.Key<T>,
    private val defaultValue: T
) : ReadWriteProperty<Any, T> {

    @WorkerThread
    override fun getValue(thisRef: Any, property: KProperty<*>) =
        dataStore.get(key = key, defaultValue = defaultValue)

    override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
        dataStore.set(key = key, value = value)
    }
}

代码库: https://github.com/egorikftp/Lady-happy-Android

Learn with code: Jetpack Compose — Lists and Pagination (Part 1)

这个文章做了一个游戏浏览app, 用的api是这个:
https://rawg.io/apidocs

对于列表的显示, 用的是LazyVerticalGrid, 并且用Paging3做了分页.

图像加载用的是Coil: https://coil-kt.github.io/coil/compose/

最后还讲了ui测试.

Realtime Selfie Segmentation In Android With MLKit

image segmentation: 图像分割, 把主体和背景分隔开.

居然还有这么一个网站: https://paperswithcode.com/task/semantic-segmentation
感觉是结合学术与工程的.

ML Kit提供了自拍背景分离:
https://developers.google.com/ml-kit/vision/selfie-segmentation

作者的所有文章:
https://gist.github.com/shubham0204/94c53703eff4e2d4ff197d3bc8de497f

本文余下部分讲了demo实现.

Interfaces and Abstract Classes in Kotlin

Kotlin中的接口和抽象类.

Do more with your widget in Android 12!

Android 12的widgets, 可以在主屏显示一个todo list.

Sample code: https://github.com/android/user-interface-samples/tree/main/AppWidget

Performance and Velocity: How Duolingo Adopted MVVM on Android

Duolingo的技术重构.

他们的app取得成功之后, 要求feature快速开发, 因为缺乏一个可扩展性的架构导致了很多问题, 其中可见的比如ANR和掉帧, 崩溃率, 缓慢.

他们经过观察发现问题的发生在一个一个全局的State对象上.

这个技术栈不但导致了性能问题, 也导致了开发效率的降低, 所以他们内部决定停掉一切feature的开发, 整个team做这项重构, 叫做Android Reboot.

Introduction to Hilt in the MAD Skills series

MAD Skills系列的Hilt介绍.

Migrating to Compose - AndroidView

把App迁移到Compose, 势必要用到AndroidView来做一些旧View的复用.

本文介绍如何用AndroidViewAndroidViewBinding.

Building Android Conversation Bubbles

Slack如何在Android 11上实现Conversation Bubbles.

文章的图不错.

websocket的资料:
https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API

KaMP Kit goes Jetpack Compose

KMP + Compose的sample.

Code

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,928评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,192评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,468评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,186评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,295评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,374评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,403评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,186评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,610评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,906评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,075评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,755评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,393评论 3 320
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,079评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,313评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,934评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,963评论 2 351

推荐阅读更多精彩内容