将 Android 项目迁移至 Kotlin Coroutines

前言

在2017年 Google I/O 大会上, Google 宣布了 Android 平台对 Kotlin 语言的官方支持. 我思考了很久如何向没有听说过 Kotlin 语言的开发者介绍它呢? 用这个知乎的段子应该是最合适不过了:

Scala:想解决 Java 表达能力不足的问题

Groovy:想解决 Java 语法过于冗长的问题

Clojure:想解决 Java 没有函数式编程的问题

Kotlin:想解决 Java

段子归段子, 事实上, Kotlin 在国外公司的应用已经十分广泛, 如 Pinterest, Gradle, Evernote, Uber, Trello, Square, Google 等等. 那么为什么要使用 Kotlin 呢?换言之, 相比于 Java, Kotlin 能给我带来什么好处?

解决了 Why 的问题, 下面我们来解决 How to 的问题.

开始迁移

首先介绍一下本次项目的相关内容. 项目名称为纸飞机(https://github.com/TonnyL/PaperPlane), 是一个集合知乎日报, 豆瓣一刻和果壳精选的综合性阅读 App[2], 项目参考了 Google 推出的 Android Architecture Blueprintstodo-mvp 的 MVP 架构, 本次迁移仍然沿袭 MVP 架构, 主要的变化来自 Kotlin 语言以及 Kotlin Coroutines 的应用.

Screenshot of PaperPlane
Screenshot of PaperPlane

Kotlin IDE 插件

如果你在使用 Android Studio 2.3 及以下版本, 请升级到3.0及以上吧, 如果你还在使用 Eclipse 开发 Android 项目, 嗯…🤔⬇️

If you still develop Android on Eclipse
If you still develop Android on Eclipse

Android Studio 3.0 集成了 Kotlin IDE 插件, 你可以在 Android Studio -> Preferences -> Plugins -> Kotlin 找到. 当然, 你要是不嫌烦的话, 可以卸载后重新安装, 安装完成后重启 Android Studio(我之前遇到过升级 Kotlin 版本后项目炸裂的情况, 使用此方法有奇效).

升级 Gradle

在项目的 build.gradle 文件中添加对 Kotlin 的支持:

buildscript {
    ext.versions = [
            'android_gradle'            : '3.2.0-alpha15',
            'kotlin'                    : '1.2.41',
            'support_library'           : '27.1.1',
            'arch_room'                 : '1.1.0',
            'retrofit'                  : '2.4.0',
            'okhttp_logging_interceptor': '3.10.0',
            'date_time_picker'          : '3.6.0',
            'coroutines'                : '0.22.5',
            'mockito'                   : '2.8.47',
            'hamcrest'                  : '1.3',
            'glide'                     : '4.7.1',
            'junit'                     : '4.12',
            'support_test'              : '1.0.1',
            'espresso'                  : '3.0.1'
    ]
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:${versions.android_gradle}"
        // Add the classpath to support Kotlin
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
    }
}

仅仅添加 classpath 还不够, 还需要在 Module 级别(如 app 目录下)的 build.gradle 添加 Kotlin 标准库依赖和 Kotlin 相关插件等:

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'

android {
    // ...

    kapt {
        correctErrorTypes = true
    }
}

// enable Parcelize
androidExtensions {
    experimental = true
}

// enable coroutines
kotlin {
    experimental {
        coroutines "enable"
    }
}

dependencies {
    implementation fileTree(include: ['*.jar'], dir: 'libs')

    // ...

    // Kotlin standard library
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}"

    implementation "com.github.bumptech.glide:glide:${versions.glide}"
    // use kapt instead of annotationProcessor
    kapt "com.github.bumptech.glide:compiler:${versions.glide}"

    // Kotlin coroutines
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.coroutines}"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.coroutines}"

    // ...
}

使用 Kotlin 后, annotationProcessor 就不要再用了, 取而代之的是 kapt:

apply plugin: 'kotlin-kapt'

kapt "com.github.bumptech.glide:compiler:${versions.glide}"

为了使用 Coroutines, 我们还需要开启 kotlin-experimental, 这是一项处于实验中的特性.

kotlin {
    experimental {
        coroutines "enable"
    }
}

到这里, Kotlin 的开发环境就配置好了. 以上是 Android Studio 的配置教程, 据我所知使用 Intellij IDEA 进行 Android 开发的开发者也有很多, 配置方式其实大同小异, 毕竟 Android Studio 也是 Powered by Intellij IDEA.

将 Java 代码转换为 Kotlin代码

Android Studio 集成了一个将 Java 代码转换为 Kotlin 代码的工具, 选择 Code -> Convert Java File to Kotlin File (快捷键 Option ⌥ + Shift ⇧ + Command ⌘ + K )即可使用, 非常简单.

转换后的代码可能还需要我们手动的修改. 我们来看个实例, 我们将一个名为 ZhihuDailyNewsQuestion 的 POJO 类由 Java 转换为 Kotlin:

ZhihuDailyNewsQuestion.java

/**
 * Immutable model class for zhihu daily news question.
 * Entity class for {@link com.google.gson.Gson} and {@link android.arch.persistence.room.Room}.
 */
@Entity(tableName = "zhihu_daily_news")
@TypeConverters(StringTypeConverter.class)
public class ZhihuDailyNewsQuestion {

    @ColumnInfo(name = "images")
    @Expose
    @SerializedName("images")
    private List<String> images;

    @ColumnInfo(name = "type")
    @Expose
    @SerializedName("type")
    private int type;

    @PrimaryKey
    @ColumnInfo(name = "id")
    @Expose
    @SerializedName("id")
    private int id;

    @ColumnInfo(name = "ga_prefix")
    @Expose
    @SerializedName("ga_prefix")
    private String gaPrefix;

    @ColumnInfo(name = "title")
    @Expose
    @SerializedName("title")
    private String title;

    @ColumnInfo(name = "favorite")
    @Expose
    private boolean favorite;

    @ColumnInfo(name = "timestamp")
    @Expose
    private long timestamp;

    public List<String> getImages() {
        return images;
    }

    public void setImages(List<String> images) {
        this.images = images;
    }

    public int getType() {
        return type;
    }

    public void setType(int type) {
        this.type = type;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getGaPrefix() {
        return gaPrefix;
    }

    public void setGaPrefix(String gaPrefix) {
        this.gaPrefix = gaPrefix;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public boolean isFavorite() {
        return favorite;
    }

    public void setFavorite(boolean favorite) {
        this.favorite = favorite;
    }

    public long getTimestamp() {
        return timestamp;
    }

    public void setTimestamp(long timestamp) {
        this.timestamp = timestamp;
    }
}

ZhihuDailyNewsQuestion.kt

/**
 * Immutable model class for zhihu daily news question.
 * Entity class for [com.google.gson.Gson] and [android.arch.persistence.room.Room].
 */
@Entity(tableName = "zhihu_daily_news")
@TypeConverters(StringTypeConverter::class)
@Parcelize
@SuppressLint("ParcelCreator")
data class ZhihuDailyNewsQuestion(

        @ColumnInfo(name = "images")
        @Expose
        @SerializedName("images")
        val images: List<String>?,

        @ColumnInfo(name = "type")
        @Expose
        @SerializedName("type")
        val type: Int,

        @PrimaryKey
        @ColumnInfo(name = "id")
        @Expose
        @SerializedName("id")
        val id: Int = 0,

        @ColumnInfo(name = "ga_prefix")
        @Expose
        @SerializedName("ga_prefix")
        val gaPrefix: String,

        @ColumnInfo(name = "title")
        @Expose
        @SerializedName("title")
        val title: String,

        @ColumnInfo(name = "favorite")
        @Expose
        var isFavorite: Boolean = false,

        @ColumnInfo(name = "timestamp")
        @Expose
        var timestamp: Long = 0

) : Parcelable

我们首先从代码行就可以很清晰的看出来, Kotlin 版本(76行)相比 Java 版本(129行)减少了约40%, 而且请注意, Java 版本没有包含序列化(Parcelable)的部分. 并且, Kotlin 的 Data Class 自动生成了 getterssetters、 equals()、 hashCode()、 toString() 以及 copy() 等等方法.

Data Class 只是 Kotlin 众多特性的一种, 如果你想要了解更多内容, 请访问 Kotlin 官方网站Kotlin 语言中文站.

迁移至 Coroutines

本次文章的重点便是如何迁移至 Coroutines.

Coroutines( 协程) 通过将复杂性放入库来简化异步编程。程序的逻辑可以在协程中顺序地表达,而底层库会为我们解决其异步性。该库可以将用户代码的相关部分包装为回调、订阅相关事件、在不同线程(甚至不同机器)上调度执行,而代码则保持如同顺序执行一样简单。

官方定义总是这样, 将一堆容易理解的文字组合成难以理解的句子🙃. 简单来说, Coroutines 简化了异步编程, 让我们可以顺序地表达程序. 同时, Coroutines 提供了一种避免阻塞线程并用更廉价,更可控的操作替代线程阻塞的方法 – 协程挂起[3].

在未迁移之前, 我们所有的异步操作都是通过实现了 XXXXDataSource 接口的 XXXXRepository, XXXXLocalDataSource, XXXXRemoteDataSource实现的(为了方便起见, 如无特别说明, 后面均以知乎日报的实现部分举例).

ZhihuDailyNewsDataSource中的方法因为可能进行密集操作, 因此将这些方法添加 suspend 关键字. 将 callback 替换为 返回 Result 对象.

ZhihuDailyNewsDataSource.java

public interface ZhihuDailyNewsDataSource { 

    interface LoadZhihuDailyNewsCallback {  

        void onNewsLoaded(@NonNull List<ZhihuDailyNewsQuestion> list);  

        void onDataNotAvailable();  

    }   

    interface GetNewsItemCallback { 

        void onItemLoaded(@NonNull ZhihuDailyNewsQuestion item);    

        void onDataNotAvailable();  

    }   

    void getZhihuDailyNews(boolean forceUpdate, boolean clearCache, long date, @NonNull LoadZhihuDailyNewsCallback callback);   

    void getFavorites(@NonNull LoadZhihuDailyNewsCallback callback);    

    void getItem(int itemId, @NonNull GetNewsItemCallback callback);    

    void favoriteItem(int itemId, boolean favorite);    

    void saveAll(@NonNull List<ZhihuDailyNewsQuestion> list);   

}

ZhihuDailyNewsDataSource.kt

interface ZhihuDailyNewsDataSource {

    interface LoadZhihuDailyNewsCallback {

        fun onNewsLoaded(list: List<ZhihuDailyNewsQuestion>)

        fun onDataNotAvailable()

    }

    interface GetNewsItemCallback {

        fun onItemLoaded(item: ZhihuDailyNewsQuestion)

        fun onDataNotAvailable()

    }

    fun getZhihuDailyNews(forceUpdate: Boolean, clearCache: Boolean, date: Long, callback: LoadZhihuDailyNewsCallback)

    fun getFavorites(callback: LoadZhihuDailyNewsCallback)

    fun getItem(itemId: Int, callback: GetNewsItemCallback)

    fun favoriteItem(itemId: Int, favorite: Boolean)

    fun saveAll(list: List<ZhihuDailyNewsQuestion>)

}

Result 是一个 sealed (密封)类, 可能为 Success 或者 Error. 之前所有接受回调作为参数的方法, 现在都改成返回 Result 对象.

Result.kt

sealed class Result<out T : Any> {

    class Success<out T : Any>(val data: T) : Result<T>()

    class Error(val exception: Throwable) : Result<Nothing>()

}

然后我们来修改具体类中的实现方法, 首先是从本地数据库中获取数据, 如 getZhihuDailyNews():

ZhihuDailyNewsLocalDataSource.java#getZhihuDailyNews

@Override   
public void getZhihuDailyNews(boolean forceUpdate, boolean clearCache, long date, @NonNull LoadZhihuDailyNewsCallback callback) {   

    if (mDb == null) {  
        mDb = DatabaseCreator.getInstance().getDatabase();  
    }   

    if (mDb != null) {  
        new AsyncTask<Void, Void, List<ZhihuDailyNewsQuestion>>() { 

            @Override   
            protected List<ZhihuDailyNewsQuestion> doInBackground(Void... voids) {  
                return mDb.zhihuDailyNewsDao().queryAllByDate(date);    
            }   

            @Override   
            protected void onPostExecute(List<ZhihuDailyNewsQuestion> list) {   
                super.onPostExecute(list);  
                if (list == null) { 
                    callback.onDataNotAvailable();  
                } else {    
                    callback.onNewsLoaded(list);    
                }   
            }   

        }.execute();    
    }   
}

ZhihuDailyNewsLocalDataSource.kt#getZhihuDailyNews

override suspend fun getZhihuDailyNews(forceUpdate: Boolean, clearCache: Boolean, date: Long): Result<List<ZhihuDailyNewsQuestion>> = withContext(mAppExecutors.ioContext) {
    val news = mZhihuDailyNewsDao.queryAllByDate(date)
    if (news.isNotEmpty()) Result.Success(news) else Result.Error(LocalDataNotFoundException())
}

Repository 中之前的回调, 现在可以替换为 if 或 when 代码块:

ZhihuDailyNewsRepository.java#getZhihuDailyNews

@Override   
public void getZhihuDailyNews(boolean forceUpdate, boolean clearCache, long date, @NonNull LoadZhihuDailyNewsCallback callback) {   

    if (mCachedItems != null && !forceUpdate) { 
        callback.onNewsLoaded(new ArrayList<>(mCachedItems.values()));  
        return; 
    }   

    // Get data by accessing network first. 
    mRemoteDataSource.getZhihuDailyNews(false, clearCache, date, new LoadZhihuDailyNewsCallback() { 
        @Override   
        public void onNewsLoaded(@NonNull List<ZhihuDailyNewsQuestion> list) {  
            refreshCache(clearCache, list); 
            callback.onNewsLoaded(new ArrayList<>(mCachedItems.values()));  
            // Save these item to database. 
            saveAll(list);  
        }   

        @Override   
        public void onDataNotAvailable() {  
            mLocalDataSource.getZhihuDailyNews(false, false, date, new LoadZhihuDailyNewsCallback() {   
                @Override   
                public void onNewsLoaded(@NonNull List<ZhihuDailyNewsQuestion> list) {  
                    refreshCache(clearCache, list); 
                    callback.onNewsLoaded(new ArrayList<>(mCachedItems.values()));  
                }   

                @Override   
                public void onDataNotAvailable() {  
                    callback.onDataNotAvailable();  
                }   
            }); 
        }   
    }); 

}

ZhihuDailyNewsRepository.kt#getZhihuDailyNews

override suspend fun getZhihuDailyNews(forceUpdate: Boolean, clearCache: Boolean, date: Long): Result<List<ZhihuDailyNewsQuestion>> {
    if (!forceUpdate) {
        return Result.Success(mCachedItems.values.toList())
    }

    val result = mRemoteDataSource.getZhihuDailyNews(false, clearCache, date)
    return if (result is Result.Success) {
        refreshCache(clearCache, result.data)
        saveAll(result.data)

        result
    } else {
        mLocalDataSource.getZhihuDailyNews(false, false, date).also {
            if (it is Result.Success) {
                refreshCache(clearCache, it.data)
            }
        }
    }
}

最后, 我们在 Presenter 中调用时, 代码变成了线性的, 而不是之前的大段回调:

ZhihuDailyPresenter.java#loadNews

@Override   
public void loadNews(boolean forceUpdate, boolean clearCache, long date) {  

    mRepository.getZhihuDailyNews(forceUpdate, clearCache, date, new ZhihuDailyNewsDataSource.LoadZhihuDailyNewsCallback() {    
        @Override   
        public void onNewsLoaded(@NonNull List<ZhihuDailyNewsQuestion> list) {  
            if (mView.isActive()) { 
                mView.showResult(list); 
                mView.setLoadingIndicator(false);   
            }   
        }   

        @Override   
        public void onDataNotAvailable() {  
            if (mView.isActive()) { 
                mView.setLoadingIndicator(false);   
            }   
        }   
    }); 
}

ZhihuDailyPresenter.kt#loadNews

override fun loadNews(forceUpdate: Boolean, clearCache: Boolean, date: Long) = launchSilent(uiContext) {
    val result = mRepository.getZhihuDailyNews(forceUpdate, clearCache, date)
    if (mView.isActive) {
        if (result is Result.Success) {
            mView.showResult(result.data.toMutableList())
        }

        mView.setLoadingIndicator(false)
    }
}

到这里, 使用 Coroutines 替代 Callback 的工作基本完成. 迁移到 Kotlin Coroutines 使得代码更加干净也更加容易理解了.

学习 Kotlin 的资源

参考文章

[1] 来源: [https://www.kotlincn.net/docs/reference/comparison-to-java.html#kotlin-%E6%9C%89%E8%80%8C-java-%E6%B2%A1%E6%9C%89%E7%9A%84%E4%B8%9C%E8%A5%BF]()

[2] 关于项目的开发历程, 请参考如何用一周时间开发一款 Android APP 并在 Google Play 上线, 关于应用中全局字体的应用和夜间模式的实现, 请分别参考: 简单高效的实现 Android App 全局字体替换, 简洁优雅地实现夜间模式.

[3] 协程的官方文档: http://kotlinlang.org/docs/reference/coroutines.html

本文参考了:

Migrating todo-mvp-kotlin to coroutines, 作者 Dmytro Danylyk, 是一名 Android Google Developer Expert (GDE).

本文由 TonnyL 创作, 发表在 Tonny’s Blog , 转载请遵守 CC BY-NC-ND 4.0 协议.

Leave a Reply