用 Kotlin 開發 Android 專案是一種什麼樣的感受?

neverwoods發表於2019-03-02

前言

從初學 Kotlin,到嘗試性的寫一點體驗程式碼,再到實驗性的做一些封裝工作,到最後摸爬滾打著寫了一個專案。不得不說過程中還是遇上了不少的問題,儘管有不少坑是源於我自己的選擇,比如使用了 anko 佈局放棄了 xml,但是總體來說,這門語言帶給我的驚喜是完全足以讓我忽略路上的坎坷。

這篇文章僅僅是想整理一下這一路走過來的一些感想和驚喜,隨著我對 Kotlin 的學習和使用,會長期修改。

正文

1.有了空安全,再也不怕服務端返回空物件了

簡單一點的例子,那就是 String 和 String?是兩種不同的型別。String 已經確定是不會為空,一定有值;而 String?則是未知的,也許有值,也許是空。在使用物件的屬性和方法的時候,String 型別的物件可以毫無顧忌的直接使用,而 String?型別需要你先做非空判斷。

fun demo() {
    val string1: String = "string1"
    val string2: String? = null
    val string3: String? = "string3"
    
    println(string1.length)
    println(string2?.length)
    println(string3?.length)
}
複製程式碼
輸出結果為:
7
null
7
複製程式碼

儘管 string2 是一個空物件,也並沒有因為我呼叫了它的屬性/方法就報空指標。而你所需要做的,僅僅是加一個”?”。

如果說這樣還體現不出空安全的好處,那麼看下面的例子:

val a: A? = A()
println(a?.b?.c)
複製程式碼

試想一下當每一級的屬性皆有可能為空的時候,JAVA 中我們需要怎麼處理?

2.轉型與智慧轉換,省力又省心

我寫過這樣子的 JAVA 程式碼

if(view instanceof TextView) {
    TextView textView = (TextView) view;
    textView.setText("text");
}
複製程式碼

而在 Kotlin 中的寫法則有所不同

if(view is TextView) {
    TextView textView = view as TextView
    textView.setText("text")
}
複製程式碼

縮減程式碼之後對比更加明顯

JAVA

if(view instanceof TextView) {
    ((TextView) view).setText("text");
}

Kotlin

if(view is TextView) {
    (view as TextView).setText("text")
}

複製程式碼

相比於 JAVA 在物件前加 (Class) 這樣子的寫法,Kotlin 是在物件之後新增 as Class 來實現轉型。至少我個人而言,在習慣了 as Class 順暢的寫法之後,是再難以忍受 JAVA 中前置的寫法,哪怕有 cast 快捷鍵的存在,仍然很容易打斷我寫程式碼的順序和思路

事實上,Kotlin 此處可以更簡單:

if(view is TextView) {
    view.setText("text")
}
複製程式碼

因為當前上下文已經判明 view 就是 TextView,所以在當前程式碼塊中 view 不再是 View 類,而是 TextView 類。這就是 Kotlin 的智慧轉換

接著上面的空安全來舉個例子,常規思路下,既然 String 和 String? 是不同的型別,是不是我有可能會寫出這樣的程式碼?

val a: A? = A()
if (a != null) {
    println(a?.b)
}
複製程式碼

這樣子寫,Kotlin 反而會給你顯示一個高亮的警告,說這是一個不必要的 safe call。至於為什麼,因為你前面已經寫了 a != null 了啊,於是 a 在這個程式碼塊裡不再是 A? 型別, 而是 A 型別。

val a: A? = A()
if (a != null) {
    println(a.b)
}
複製程式碼

智慧轉換還有一個經常出現的場景,那就是 switch case 語句中。在 Kotlin 中,則是 when 語法。

fun testWhen(obj: Any) {
    when(obj) {
        is Int -> {
            println("obj is a int")
            println(obj + 1)
        }

        is String -> {
            println("obj is a string")
            println(obj.length)
        }

        else -> {
            println("obj is something i don`t care")
        }
    }
}

fun main(args: Array<String>) {
    testWhen(98)
    testWhen("98")
}
複製程式碼
輸出如下:
obj is a int
99
obj is a string
2
複製程式碼

可以看出在已經判斷出是 String 的條件下,原本是一個 Any 類的 obj 物件,我可以直接使用屬於 String 類的 .length 屬性。而在 JAVA 中,我們需要這樣做:

System.out.println("obj is a string")
String string = (String) obj;
System.out.println(string.length)
複製程式碼

或者

System.out.println("obj is a string")
System.out.println(((String) obj).length)
複製程式碼

前者打斷了編寫和閱讀的連貫性,後者嘛。。

Kotlin 的智慧程度遠不止如此,即便是現在,在編寫程式碼的時候還會偶爾蹦一個高亮警告出來,這時候我才知道原來我的寫法是多餘的,Kotlin 已經幫我處理了好了。此處不再一一贅述。

3.比 switch 更強大的 when

通過上面智慧轉化的例子,已經展示了一部分 when 的功能。但相對於 JAVA 的 switch,Kotlin 的 when 帶給我的驚喜遠遠不止這麼一點。

例如:

fun testWhen(int: Int) {
    when(int) {
        in 10 .. Int.MAX_VALUE -> println("${int} 太大了我懶得算")
        2, 3, 5, 7 -> println("${int} 是質數")
        else -> println("${int} 不是質數")
    }
}

fun main(args: Array<String>) {
    (0..10).forEach { testWhen(it) }
}
複製程式碼
輸出如下:
0 不是質數
1 不是質數
2 是質數
3 是質數
4 不是質數
5 是質數
6 不是質數
7 是質數
8 不是質數
9 不是質數
10 太大了我懶得算
複製程式碼

和 JAVA 中死板的 switch-case 語句不同,在 when 中,我既可以用引數去匹配 10 到 Int.MAX_VALUE 的區間,也可以去匹配 2, 3, 5, 7 這一組值,當然我這裡沒有列舉所有特性。when 的靈活、簡潔,使得我在使用它的時候變得相當開心(和 JAVA 的 switch 對比的話)

4.容器的操作符

自從迷上 RxJava 之後,我實在很難再回到從前,這其中就有 RxJava 中許多方便的操作符。而 Kotlin 中,容器自身帶有一系列的操作符,可以非常簡潔的去實現一些邏輯。

例如:

(0 until container.childCount)
        .map { container.getChildAt(it) }
        .filter { it.visibility == View.GONE }
        .forEach { it.visibility = View.VISIBLE }
複製程式碼

上述程式碼首先建立了一個 0 到 container.childCount – 1 的區間;再用 map 操作符配合取出 child 的程式碼將這個 Int 的集合轉化為了 childView 的集合;然後在用 filter 操作符對集合做篩選,選出 childView 中所有可見性為 GONE 的作為一個新的集合;最終 forEach 遍歷把所有的 childView 都設定為 VISIBLE。

這裡再貼上 JAVA 的程式碼作為對比。

for(int i = 0; i < container.childCount - 1;  i++) {
    View childView = container.getChildAt(i);
    if(childView.getVisibility() == View.GONE) {
        childView.setVisibility(View.VISIBLE);
    }
}
複製程式碼

這裡就不詳細的去描述這種鏈式的寫法有什麼優點了。

5.執行緒切換,so easy

既然上面提到了 RxJava,不得不想起 RxJava 的另一個優點——執行緒排程。Kotlin 中有一個專為 Android 開發量身打造的庫,名為 anko,其中包含了許多可以簡化開發的程式碼,其中就對執行緒進行了簡化。

async {
    val response = URL("https://www.baidu.com").readText()
    uiThread {
        textView.text = response
    }
}
複製程式碼

上面的程式碼很簡單,通過 async 方法將程式碼實現在一個非同步的執行緒中,在讀取到 http 請求的響應了之後,再通過 uiThread 方法切換回 ui 執行緒將 response 顯示在 textView 上。

拋開內部的實現,你再也不需要為了一個簡簡單單的非同步任務去寫一大堆的無效程式碼。按照慣例,這裡似乎應該貼上 JAVA 的程式碼做對比,但請原諒我不想刷屏(啊哈哈)

6.一個關鍵字實現單例

沒錯,就是一個關鍵字就可以實現單例:

object Log {
    fun i(string: String) {
        println(string)
    }
}

fun main(args: Array<String>) {
    Log.i("test")
}
複製程式碼

再見,單例模式

7.自動 getter、setter 及 class 簡潔宣告

JAVA 中有如下類

class Person {
    private String name;

    public Person(String name) {
        this.name = name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void getName() {
        return name;
    }
}

Person person = new Person("張三");
複製程式碼

可以看出,標準寫法下,一個屬性對應了 get 和 set 兩個方法,需要手動寫的程式碼量相當大。當然有快捷鍵幫助我們生成這些程式碼,但是考慮到各種複雜情形總歸不完美。

而 Kotlin 中是這樣的:

class Person(var name: String)
val person = Person("張三");
複製程式碼

還可以新增預設值:

class Person(var name: String = "張三")
val person = Person()
複製程式碼

再附上我專案中一個比較複雜的資料類:

data class Column(
        var subId: String?,
        var subTitle: String?,
        var subImg: String?,
        var subCreatetime: String?,
        var subUpdatetime: String?,
        var subFocusnum: Int?,
        var lastId: String?,
        var lastMsg: String?,
        var lastType: String?,
        var lastMember: String?,
        var lastTIme: String?,
        var focus: String?,
        var subDesc: String?,
        var subLikenum: Int?,
        var subContentnum: Int?,
        var pushSet: String?
)
複製程式碼

一眼望去,沒有多餘程式碼。這是為什麼我認為 Kotlin 程式碼比 JAVA 程式碼要更容易寫得乾淨的原因之一。

8. DSL 式程式設計

說起 dsl ,Android 開發者接觸的最多的或許就是 gradle 了

例如:

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.2"

    defaultConfig {
        applicationId "com.zll.demo"
        minSdkVersion 15
        targetSdkVersion 23
        versionCode 1
        versionName "1.0"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile(`proguard-android.txt`), `proguard-rules.pro`
        }
    }
}
複製程式碼

這就是一段 Groovy 的 DSL,用來宣告編譯配置

那麼在 Android 專案的程式碼中使用 DSL 是一種什麼樣的感覺呢?

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val homeFragment = HomeFragment()
    val columnFragment = ColumnFragment()
    val mineFragment = MineFragment()

    setContentView(
            tabPages {
                backgroundColor = R.color.white
                dividerColor = R.color.colorPrimary
                behavior = ByeBurgerBottomBehavior(context, null)

                tabFragment {
                    icon = R.drawable.selector_tab_home
                    body = homeFragment
                    onSelect { toast("home selected") }
                }

                tabFragment {
                    icon = R.drawable.selector_tab_search
                    body = columnFragment
                }

                tabImage {
                    imageResource = R.drawable.selector_tab_photo
                    onClick { showSheet() }
                }

                tabFragment {
                    icon = R.drawable.selector_tab_mine
                    body = mineFragment
                }
            }
    )
}
複製程式碼
效果圖

沒錯,上面的程式碼就是用來構建這個主介面的 viewPager + fragments + tabBar 的。以 tabPages 作為開始,設定背景色,分割線等屬性;再用 tabFrament 新增 fragment + tabButton,tabImage 方法則只新增 tabButton。所見的程式碼都是在做配置,而具體的實現則被封裝了起來。

前面提到過 anko 這個庫,其實也可以用來替代 xml 做佈局用:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    verticalLayout {
        textView {
            text = "這是標題"
        }.lparams {
            width = matchParent
            height = dip(44)
        }

        textView {
            text = "這是內容"
            gravity = Gravity.CENTER
        }.lparams {
            width = matchParent
            height = matchParent
        }
    }
}
複製程式碼

相比於用 JAVA 程式碼做佈局,這種 DSL 的方式也是在做配置,把佈局的實現程式碼封裝在了背後,和 xml 佈局很接近。

關於 DSL 和 anko 佈局,以後會有專門的文章做介紹,這裡就此打住。

9.委託/代理,SharedPreference 不再麻煩

通過 Kotlin 中的委託功能,我們能輕易的寫出一個 SharedPreference 的代理類

class Preference<T>(val context: Context, val name: String?, val default: T) : ReadWriteProperty<Any?, T> {
    val prefs by lazy {
        context.getSharedPreferences("xxxx", Context.MODE_PRIVATE)
    }

    override fun getValue(thisRef: Any?, property: KProperty<*>): T = with(prefs) {
        val res: Any = when (default) {
            is Long -> {
                getLong(name, 0)
            }
            is String -> {
                getString(name, default)
            }
            is Float -> {
                getFloat(name, default)
            }
            is Int -> {
                getInt(name, default)
            }
            is Boolean -> {
                getBoolean(name, default)
            }
            else -> {
                throw IllegalArgumentException("This type can`t be saved into Preferences")
            }
        }
        res as T
    }

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) = with(prefs.edit()) {
        when (value) {
            is Long -> putLong(name, value)
            is String -> putString(name, value)
            is Float -> putFloat(name, value)
            is Int -> putInt(name, value)
            is Boolean -> putBoolean(name, value)
            else -> {
                throw IllegalArgumentException("This type can`t be saved into Preferences")
            }
        }.apply()
    }
}
複製程式碼

暫且跳過原理,我們去看怎麼使用

class EntranceActivity : BaseActivity() {
    
    private var userId: String by Preference(this, "userId", "")

    override fun onCreate(savedInstanceState: Bundle?) {
        testUserId()
    }
    
    fun testUserId() {
        if (userId.isEmpty()) {
            println("userId is empty")
            userId = "default userId"
        } else {
            println("userId is $userId")
        }
    }
}
複製程式碼
重複啟動 app 輸出結果:
userId is empty
userId is default userId
userId is default userId
...
複製程式碼

第一次啟動 app 的時候從 SharedPreference 中取出來的 userId 是空的,可是後面卻不為空。由此可見,userId = “default userId” 這句程式碼成功的將 SharedPreference 中的值修改成功了。

也就是說,在這個 Preference 代理的幫助下,SharedPreference 存取操作變得和普通的物件呼叫、賦值一樣的簡單。

10.擴充套件,和工具類說拜拜

很久很久以前,有人和我說過,工具類本身就是一種違反物件導向思想的東西。可是當時我就想了,你不讓我用工具類,那有些程式碼我該怎麼寫呢?直到我知道了擴充套件這個概念,我才豁然開朗。

fun ImageView.displayUrl(url: String?) {
    if (url == null || url.isEmpty() || url == "url") {
        imageResource = R.mipmap.ic_launcher
    } else {
        Glide.with(context)
                .load(ColumnServer.SERVER_URL + url)
                .into(this)
    }
}
...
val imageView = findViewById(R.id.avatarIv) as ImageView
imageView.displayUrl(url)
複製程式碼

上述程式碼可理解為:

1.我給 ImageView 這個類擴充套件了一個名為 displayUrl 的方法,這個方法接收一個名為 url 的 String?類物件。如不出意外,會通過 Glide 載入這個 url 的圖片,顯示在當前的 imageView 上;

2.我在另一個地方通過 findViewById 拿到了一個 ImageView 類的例項,然後呼叫這個 imageView 的displayUrl 方法,試圖載入我傳入的 url

通過擴充套件來為 ImageView 新增方法,相比於通過繼承 ImageView 來寫一個 CustomImageView,再新增方法而言,侵入性更低,不需要在程式碼中全寫 CustomImageView,也不需要在 xml 佈局中將包名寫死,造成移植的麻煩。

這事用工具類當然也可以做,比如做成 ImageUtil.displayUrl(imageView, url),但是工具類閱讀起來並沒有擴充套件出來的方法讀起來更自然更流暢。

擴充套件是 Kotlin 相比於 JAVA 的一大殺器

目前先寫到這裡,後續還會有更新~~

相關文章