【譯】使用Kotlin從零開始寫一個現代Android 專案-Part1

依然飯特稀西發表於2020-08-21

前言

經常在medium.com上看到一些高質量的技術帖子,但是由於國內的上網環境或者有的同學對於看英文比較排斥,錯過了不少好文章。因此,西哥決定弄一個《優質譯文專欄》,花一些時間翻譯一些優質技術文給大家。這篇文章是一個小系列,用Kotlin開發現代Android APP,總共四篇,後面的會陸續翻譯!以下是正文。

現在,真的很難找到一個涵蓋所有Android新技術的專案,因此我決定自己來寫一個,在本文中,我們將用到如下技術:

  • 0 、Android Studio
  • 1、Kotlin 語言
  • 2、構建變體
  • 3、ConstraintLayout
  • 4、DataBinding庫
  • 5、MVVM+repository+Android Manager架構模式
  • 6、RxJava2及其對架構的幫助
  • 7、Dagger 2.11,什麼是依賴注入?為什麼要使用它?
  • 8、Retrofit + RxJava2 實現網路請求
  • 9、RooM + RxJava2 實現儲存
我們的APP最終是什麼樣子?

我們的APP是一個非常簡單的應用程式,它涵蓋了上面提到的所有技術。只有一個簡單的功能:從Github 獲取googlesamples使用者下的所有倉庫,將資料儲存到本地資料庫,然後在介面展示它。

我將嘗試解釋更多的程式碼,你也可以看看你Github上的程式碼提交。

Github:https://github.com/mladenrako...

讓我們開始吧。

0、Android Studio

首先安卓Android Studio 3 beta 1(注:現在最新版為Android Studio 4.0),Android Studio 已經支援Kotlin,去到Create Android Project介面,你將在此處看到新的內容:帶有標籤的核取方塊include Kotlin support。預設情況下選中。按兩次下一步,然後選擇EmptyActivity,然後完成了。 恭喜!你用Kotlin開發了第一個Android app)

1、Kotlin

在剛才新建的專案中,你可以看到一個MainActivity.kt:

package me.mladenrakonjac.modernandroidapp

import android.support.v7.app.AppCompatActivity
import android.os.Bundle

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

.kt字尾代表了這是一個Kotlin檔案

MainActivity : AppCompatActivity() 表示我們的MainActivity繼承自AppCompatActivity

此外,所有的方法都必須有一個關鍵字fun,在Kotlin 中,你不能使用@override註解,如果你要表明方法是複寫父類或者介面的方法的話,直接使用override關鍵字,注意:它和Java不一樣,不是一個註解了。

然後,savedInstanceState: Bundle? 中的?代表什麼呢?它代表了savedInstanceState這個引數可以是Bundle或者null。Kotlin是一門null 安全語言,如果你像下面這樣寫:

var a : String

你將會得到一個編譯錯誤。因為a變數必須被初始化,並且不能為null,因此你要像這樣寫:

var a : String = "Init value"

並且,如果你執行以下操作,也會報編譯錯誤:

a = null

要想使a變數為null ,你必須這樣寫:

var a : String?

為什麼這是Kotlin語言的一個重要功能呢?因為它幫我們避免了NPE,Androd開發者已經對NPE感到厭倦了,甚至是null的發明者-Tony Hoare先生,也為發明它而道歉。假設我們有一個可以為空的nameTextView。如果為null,以下程式碼將會發生NPE:

nameTextView.setEnabled(true)

但實際上,Kotlin做得很好,它甚至不允許我們做這樣的事情。它會強制我們使用?或者!!操作符。如果我們使用?操作符:

nameTextView?.setEnabled(true)

僅當nameTextView不為null時,這行程式碼才會繼續執行。另一種情況下,如果我們使用!!操作符:

nameTextView!!.setEnabled(true)

如果nameTextView為null,它將為我們提供NPE。它只適合喜歡冒險的傢伙)

這是對Kotlin的一些介紹。我們繼續進行,我將停止描述其他Kotlin特定程式碼。

2、構建變體

通常,在開發中,如果你有兩套環境,最常見的是測試環境和生產環境。這些環境在伺服器URL圖示名稱目標api等方面可能有所不同。通常,在開始的每個專案中我都有以下內容:

  • finalProduction: 上傳Google Play 使用
  • demoProduction:該版本使用生產環境伺服器Url,並且它有著GP上的版本沒有的新功能,使用者可以在Google play 旁邊安裝,然後可以進行新功能測試和提供反饋。
  • demoTesting:和demoProduction一樣,只不過它用的是測試地址
  • mock: 對於我來說,作為開發人員和設計師而言都是很有用的。有時我們已經準備好設計,而我們的API仍未準備好。等待API準備就緒後再開始開發可不是好的解決方案。此構建變體為提供有mock資料,因此設計團隊可以對其進行測試並提供反饋。對於保證專案進度真的很有幫助,一旦API準備就緒,我們便將開發轉移到demoTesting環境。

在此應用程式中,我們將擁有所有這些變體。它們的applicationId和名稱不同。 gradle 3.0.0 flavourDimension中有一個新的api,可讓您混合不同的產品風味,因此您可以混合demominApi23風味。在我們的應用程式中,我們將僅使用“預設” 的flavorDimension。早app的build.gradle中,將此程式碼插入android {}下:

flavorDimensions "default"
    
productFlavors {

    finalProduction {
        dimension "default"
        applicationId "me.mladenrakonjac.modernandroidapp"
        resValue "string", "app_name", "Modern App"
    }

    demoProduction {
        dimension "default"
        applicationId "me.mladenrakonjac.modernandroidapp.demoproduction"
        resValue "string", "app_name", "Modern App Demo P"
    }

    demoTesting {
        dimension "default"
        applicationId "me.mladenrakonjac.modernandroidapp.demotesting"
        resValue "string", "app_name", "Modern App Demo T"
    }


    mock {
        dimension "default"
        applicationId "me.mladenrakonjac.modernandroidapp.mock"
        resValue "string", "app_name", "Modern App Mock"
    }
}

開啟string.xml檔案,刪掉app_namestring資源,因此,我們才不會發生資源衝突,然後點選Sync Now,如果轉到螢幕左側的“構建變體”,則可以看到4個不同的構建變體,其中每個都有兩種構建型別:“Debug”和“Release”,切換到demoProduction構建變體並執行它。然後切換到另一個並執行它。您就可以看到兩個名稱不同的應用程式。

3、ConstraintLayout

如果你開啟activity_main.xml ,你可以看到跟佈局是ConstraintLayout,如果你開發過iOS應用程式,你可能知道AutoLayoutConstraintLayout和它非常的相似,他們甚至用了相同的 Cassowary 演算法。

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="me.mladenrakonjac.modernandroidapp.MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

Constraints可以幫我們描述View之間的關係。對於每一個View來說,應該有4個約束,每一邊一個約束,在這種情況下,我們的View就被約束在了父檢視的每一邊了。

在Design Tab中,如果你將Hello World文字稍微向上移動,則在TextTab中將增加下面這行程式碼:

app:layout_constraintVertical_bias="0.28"

Design tab 和 Text tab是同步的,我們在Design中移動檢視,則會影響Text中的xml,反之亦然。垂直偏差描述了檢視對其約束的垂直趨勢。如果要使檢視垂直居中,則應使用:

app:layout_constraintVertical_bias="0.28"

我們讓Activity只顯示一個倉庫,它有倉庫的名字,star的數量,作者,並且還會顯示是否有issue

要得到上面的佈局設計,程式碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="me.mladenrakonjac.modernandroidapp.MainActivity">

    <TextView
        android:id="@+id/repository_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="16dp"
        android:layout_marginStart="16dp"
        android:textSize="20sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.083"
        tools:text="Modern Android app" />

    <TextView
        android:id="@+id/repository_has_issues"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="16dp"
        android:layout_marginStart="16dp"
        android:layout_marginTop="8dp"
        android:text="@string/has_issues"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="@+id/repository_name"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="1.0"
        app:layout_constraintStart_toEndOf="@+id/repository_name"
        app:layout_constraintTop_toTopOf="@+id/repository_name"
        app:layout_constraintVertical_bias="1.0" />

    <TextView
        android:id="@+id/repository_owner"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp"
        android:layout_marginEnd="16dp"
        android:layout_marginStart="16dp"
        android:layout_marginTop="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/repository_name"
        app:layout_constraintVertical_bias="0.0"
        tools:text="Mladen Rakonjac" />

    <TextView
        android:id="@+id/number_of_starts"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp"
        android:layout_marginEnd="16dp"
        android:layout_marginStart="16dp"
        android:layout_marginTop="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="1"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/repository_owner"
        app:layout_constraintVertical_bias="0.0"
        tools:text="0 stars" />

</android.support.constraint.ConstraintLayout>

不要被tools:text搞迷惑了,它的作用僅僅是讓我們可以預覽我們的佈局。

我們可以注意到,我們的佈局是扁平的,沒有任何巢狀,你應該儘量少的使用佈局巢狀,因為它會影響我們的效能。ConstraintLayout也可以在不同的螢幕尺寸下正常工作。

我有種預感,很快就能達到我們想要的佈局效果了。

上面只是一些關於ConstraintLayout的少部分介紹,你也可以看一下關於ConstraintLayout使用的google code lab: https://codelabs.developers.g...

4. Data binding library

當我聽到Data binding 庫的時候,我的第一反應是:Butterknife已經很好了,再加上,我現在使用一個外掛來從xml中獲取View,我為啥要改變,來使用Data binding呢?但當我對Data binding有了更多的瞭解之後,我的它的感覺就像我第一次見到Butterknife一樣,無法自拔。

Butterknife能幫我們做啥?

ButterKnife幫助我們擺脫無聊的findViewById。因此,如果您有5個檢視,而沒有Butterknife,則你有5 + 5行程式碼來繫結您的檢視。使用ButterKnife,您只有我行程式碼就搞定。就是這樣。

Butterknife的缺點是什麼?

Butterknife仍然沒有解決程式碼可維護問題,使用ButterKnife時,我經常發現自己遇到執行時異常,這是因為我刪除了xml中的檢視,而沒有刪除Activity/Fragment類中的繫結程式碼。另外,如果要在xml中新增檢視,則必須再次進行繫結。真的很不好維護。你將浪費大量時間來維護View繫結。

那與之相比,Data Binding 怎麼樣呢?

有很多好處,使用Data Binding,你可以只用一行程式碼就搞定View的繫結,讓我們看看它是如何工作的,首先,先將Data Binding 新增到專案:

// at the top of file 
apply plugin: 'kotlin-kapt'


android {
    //other things that we already used
    dataBinding.enabled = true
}
dependencies {
    //other dependencies that we used
    kapt "com.android.databinding:compiler:3.0.0-beta1"
}

請注意,資料繫結編譯器的版本與專案build.gradle檔案中的gradle版本相同:

classpath 'com.android.tools.build:gradle:3.0.0-beta1'

然後,點選Sync Now,開啟activity_main.xml,將Constraint Layout 用layout標籤包裹

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <android.support.constraint.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context="me.mladenrakonjac.modernandroidapp.MainActivity">

        <TextView
            android:id="@+id/repository_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="16dp"
            android:layout_marginStart="16dp"
            android:textSize="20sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.083"
            tools:text="Modern Android app" />

        <TextView
            android:id="@+id/repository_has_issues"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="16dp"
            android:layout_marginStart="16dp"
            android:layout_marginTop="8dp"
            android:text="@string/has_issues"
            android:textStyle="bold"
            app:layout_constraintBottom_toBottomOf="@+id/repository_name"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="1.0"
            app:layout_constraintStart_toEndOf="@+id/repository_name"
            app:layout_constraintTop_toTopOf="@+id/repository_name"
            app:layout_constraintVertical_bias="1.0" />

        <TextView
            android:id="@+id/repository_owner"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginBottom="8dp"
            android:layout_marginEnd="16dp"
            android:layout_marginStart="16dp"
            android:layout_marginTop="8dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/repository_name"
            app:layout_constraintVertical_bias="0.0"
            tools:text="Mladen Rakonjac" />

        <TextView
            android:id="@+id/number_of_starts"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginBottom="8dp"
            android:layout_marginEnd="16dp"
            android:layout_marginStart="16dp"
            android:layout_marginTop="8dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="1"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/repository_owner"
            app:layout_constraintVertical_bias="0.0"
            tools:text="0 stars" />

    </android.support.constraint.ConstraintLayout>

</layout>

注意,你需要將所有的xml移動到layout 標籤下面,然後點選Build圖示或者使用快捷鍵Cmd + F9,我們需要構建專案來使Data Binding庫為我們生成ActivityMainBinding類,後面在MainActivity中將用到它。

如果沒有重新編譯專案,你是看不到ActivityMainBinding的,因為它在編譯時生成。

我們還沒有完成繫結,我們只是定義了一個非空的 ActivityMainBinding 型別的變數。你會注意到我沒有把? 放在 ActivityMainBinding 的後面,而且也沒有初始化它。這怎麼可能呢?lateinit 關鍵字允許我們使用非空的延遲被初始化的變數。和 ButterKnife 類似,在我們的佈局準備完成後,初始化繫結需要在 onCreate 方法中進行。此外,你不應該在 onCreate 方法中宣告繫結,因為你很有可能在 onCreate 方法外使用它。我們的 binding 不能為空,所以這就是我們使用 lateinit 的原因。使用 lateinit 修飾,我們不需要在每次訪問它的時候檢查 binding 變數是否為空。

我們初始化binding變數,你需要替換:

setContentView(R.layout.activity_main)

為:

binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

就是這樣,你成功的繫結了所有View,現在你可以訪問它並且做一些更改,例如,我們將倉庫名字改為Modern Android Medium Article:

binding.repositoryName.text = "Modern Android Medium Article"

如你所見,現在我們可以通過bingding變數來訪問main_activity.xml的所有View了(前提是它們有id),這就是Data Binding 比ButterKnife 好用的原因。

kotlin的 Getters 和 setters

大概,你已經注意到了,我們沒有像Java那樣使用.setText(),我想在這裡暫停一下,以說明與Java相比,Kotlin中的getter和setter方法如何工作的。

首先,你需要知道,我們為什麼要使用getters和setters,我們用它來隱藏類中的變數,僅允許使用方法來訪問這些變數,這樣我們就可以向使用者隱藏類中的細節,並禁止使用者直接修改我們的類。假設我們用 Java 寫了一個 Square 類:

public class Square {
  private int a;
  
  Square(){
    a = 1;
  }

  public void setA(int a){
    this.a = Math.abs(a);
  }
  
  public int getA(){
    return this.a;
  }
  
}

使用setA()方法,我們禁止了使用者向Square類的a變數設定一個負數,因為正方形的邊長一定是正數,要使用這種方法,我們必須將其設為私有,因此不能直接設定它。這也意味著我們不能直接獲得a,需要給它定一個get方法來返回a,如果有10個變數,那麼我們就得定義10個相似的get方法,寫這樣無聊的樣板程式碼,通常會影響我們的心情。

Kotling使我們的開發人員更輕鬆了。如果你呼叫下面的程式碼:

var side: Int = square.a

這並不意味著你是在直接訪問a變數,它和Java中呼叫getA()是相同的

int side = square.getA();

因為Kotlin自動生成預設的getter和setter。在Kotlin中,只有當您有特殊的setter或getter時,才應指定它。否則,Kotlin會為您自動生成:

var a = 1
   set(value) { field = Math.abs(value) }

field ? 這又是個什麼東西?為了更清楚明白,請看下面程式碼:

var a = 1
   set(value) { a = Math.abs(value) }

這表明你在呼叫set方法中的set(value){},因為Kotlin的世界中,沒有直接訪問屬性,這就會造成無限遞迴,當你呼叫a = something,會自動呼叫set方法。使用filed就能避免無限遞迴,我希望這能讓你明白為什麼要用filed關鍵字,並且瞭解getters和setters是如何工作的。

回到程式碼中繼續,我將向你介紹Kotlin語言的另一個重要功能:apply函式:

class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding

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

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.apply {
            repositoryName.text = "Medium Android Repository Article"
            repositoryOwner.text = "Mladen Rakonjac"
            numberOfStarts.text = "1000 stars"
            
        }
    }
}

apply 允許你在一個例項上呼叫多個方法,我們仍然還沒有完成資料繫結,還有更棒的事兒,讓我們為倉庫定義一個UI模型(這個是github倉庫的資料模型Repository,它持有要展示的資料,請不要和Repository模式的中的Repository搞混淆了哈),要建立一個Kotlin class,點選New -> Kotlin File/Class :

class Repository(var repositoryName: String?,var repositoryOwner: String?,var numberOfStars: Int? ,var hasIssues: Boolean = false)

在Kotlin中,主建構函式是類頭的一部分,如果你不想定義次建構函式,那就是這樣了,資料類到此就完成了,建構函式沒有引數分配給欄位,沒有setters和getters,整個類就一行程式碼。

回到MainActivity.kt,為Repository建立一個例項。

var repository = Repository("Medium Android Repository Article",
        "Mladen Rakonjac", 1000, true)

你應該注意到了,建立類例項,沒有用new

現在,我們在activity_main.xml 中新增data標籤。

<data>
      <variable
        name="repository"
        type="me.mladenrakonjac.modernandroidapp.uimodels.Repository"
        />
</data>

我們可以在佈局中訪問儲存的變數repository,例如,我們可以如下使用id是repository_name的TextView,如下:

android:text="@{repository.repositoryName}"

repository_name文字檢視將顯示從repository變數的屬性repositoryName獲取的文字。剩下的唯一事情就是將repository變數從xml繫結到MainActivity.kt中的repository。

點選Build使DataBinding 為我們生成類,然後在MainActivity中新增兩行程式碼:

binding.repository = repository
binding.executePendingBindings()

如果你執行APP,你會看到TextView上顯示的是:“Medium Android Repository Article”,非常棒的功能,是吧?

但是,如果我們像下面這樣改一下呢?

Handler().postDelayed({repository.repositoryName="New Name"}, 2000)

新的文字將會在2000ms後顯示嗎?不會的,你必須重新設定一次repository,像這樣:

Handler().postDelayed({repository.repositoryName="New Name"
    binding.repository = repository
    binding.executePendingBindings()}, 2000)

但是,如果我們每次更改一個屬性都要這麼寫的話,那就非常蛋疼了,這裡有一個更好的方案叫做Property Observer

讓我們首先解釋一下什麼是觀察者模式,因為在rxJava部分中我們也將需要它:

可能你已經聽說過 http://androidweekly.net/,這是一個關於Android開發的週刊。如果您想接收它,則必須訂閱它並提供您的電子郵件地址。過了一段時間,如果你不想看了,你可以去網站上取消訂閱。

這就是一個觀察者/被觀察者的模式,在這個例子中, Android 週刊是被觀察者,它每週都會發布新聞通訊。讀者是觀察者,因為他們訂閱了它,一旦訂閱就會收到資料,如果不想讀了,則可以停止訂閱。

Property Observer在這個例子中就是 xml layout,它將會監聽Repository例項的變化。因此,Repository被觀察者,例如,一旦在Repository類的例項中更改了repository nane 屬性後,xml不呼叫下面的程式碼也會更新:

binding.repository = repository
binding.executePendingBindings()

如何讓它使用Data Binding 庫呢?,Data Binding庫提供了一個BaseObservable類,我們的Repostory類必須繼承它。

class Repository(repositoryName : String, var repositoryOwner: String?, var numberOfStars: Int?
                 , var hasIssues: Boolean = false) : BaseObservable(){

    @get:Bindable
    var repositoryName : String = ""
    set(value) {
        field = value
        notifyPropertyChanged(BR.repositoryName)
    }

}

當我們使用了 Bindable 註解時,就會自動生成 BR 類。你會看到,一旦設定新值,就會通知它更新。現在執行 app 你將看到倉庫的名字在 2 秒後改變而不必再次呼叫 executePendingBindings()

以上就是這一節的所有內容,下一節將會講MVVM+Repository 模式的使用。敬請期待!感謝閱讀。

作者 | Mladen Rakoajc
譯者 | 依然範特稀西
編輯 | 依然範特稀西

原文地址:https://proandroiddev.com/mod...

本系列已更新完畢:

【譯】使用Kotlin從零開始寫一個現代Android 專案-Part1

【譯】使用Kotlin從零開始寫一個現代Android 專案-Part2

【譯】使用Kotlin從零開始寫一個現代Android 專案-Part3

【譯】使用Kotlin從零開始寫一個現代Android 專案-Part4

文章首發於公眾號:「 技術最TOP 」,每天都有乾貨文章持續更新,可以微信搜尋「 技術最TOP 」第一時間閱讀,回覆【思維導圖】【面試】【簡歷】有我準備一些Android進階路線、面試指導和簡歷模板送給你

相關文章