Kotlin快速入门 - 安卓开发新趋势,Java转Kotlin开发,花一天时间就够了 - Go语言中文社区

Kotlin快速入门 - 安卓开发新趋势,Java转Kotlin开发,花一天时间就够了


前言

在Google I/O 2017中,Google 宣布 Kotlin 成为 Android 官方开发语言。

在Google I/O 2019中,Google 宣布,Kotlin 编程语言现在是 Android 应用程序开发人员的首选语言,“Android 的开发将越来越以 Kotlin 为先。” 许多新的 Jetpack API 和特性将首先在 Kotlin 中提供。

Kotlin将成为Android开发程序员的必修课。

本文既不是全面系统的学习手册,也不是对Kotlin的简单介绍,而是讲解作为一个Android程序员,想要真正快速进入Kotlin开发,所必须学习掌握的知识要点,也可以当作是入门速查手册。

如果你是一名安卓开发的Java程序员,想快速上手转Kotlin开发,那么本文就是为你而准备的了。

如果你对Kotlin已经有一定的了解,只想直接查看如何不使用接口回调而得到异步函数执行的结果,请直接跳到 协程实战 一节。

Kotlin的特点

简洁: 大大减少样板代码的数量。
安全: 避免空指针异常等错误。
互操作性: 充分利用 JVM、Android 的现有库。可以跟Java类互相访问,几乎没有桥接成本。

环境准备

安装 Kotlin 插件

Android Studio 从 3.0(preview)版本开始将内置 Kotlin 插件。

如果你的环境还没有安装Kotlin,那么打开 Settings ( Mac 为 Preferences) 面板,在右侧找到 Plugins 选项 ,搜索框输入 "Kotlin" 查找,点击 Search in repositories(仓库中搜索),然后安装即可,安装完成之后需要重启 Android Studio。

image

工程设置

新建工程,在工程向导中选择Kotlin语言,然后就可以直接开始Kotlin开发了。

如果你想在现有Java工程中添加Kotlin支持,也将非常简单,步骤如下:

  1. 选择Project视图
  2. 打开工程外层build.gradle,在buildscript中添加:
    ext.kotlin_version = '1.3.31'  
    
    在dependencies中添加:
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 
    
  3. 打开app里面的build.gradle,在文件头部中添加:
    apply plugin: 'kotlin-android'  
    apply plugin: 'kotlin-android-extensions' 
    
    在dependencies中添加:
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 
    
image
image

如图所示,Kotlin环境便成功集成进工程了。

Java工程集成Kotlin后,安装包将增大大约200~500K。

代码示范

Kotlin的最大优势就是减少样板代码量,现在先来直观感受一下如何直接访问layout xml中的元素:

  1. 新建一个Kotlin工程后;
  2. 在content_main.xml添加一个TextView控件,并将控件id修改为textView;
  3. 在MainActivity中,添加如下代码:
    textView.setText("Test Kotlin")

注意以上代码,跟Java代码差不太多,Kotlin的每行代码结束不必使用分号,将几行代码写到一行中则需要用分号分隔开。
在Android Studio IDE中,会被提示该行代码错误,使用Alt+Enter,将自动添加:

import kotlinx.android.synthetic.main.content_main.*

然后刚才的代码就可以编译运行了。
不再需要使用 findViewById(R.id.xxx),感觉是不是轻松多了。如果你使用了xml中未定义的id来访问,将无法通过编译。

单方法接口只需要写一个闭包就行了:

textView.setOnClickListener {
    Log.d("KT", "onClick")
}
textView.setOnClickListener { view ->
    Log.d("KT", "onClick:${view.toString()}") // 字符串模板中可以放任意表达式
}
textView.setOnClickListener { view, tag -> // 这里只是示范多参数,由于接口并没有tag参数,编译将出错
    Log.d("KT", "onClick:$view $tag")
}

代码转换

  • Kotlin插件提供将Java代码转换为Kotlin代码的功能,只需要在.Java文件上点右键,弹出菜单中选择"Convert Java File to Kotlin File"即可(据说某些复杂代码偶尔会出现问题)
  • 也可以将Java代码复制到剪贴板,在Kotlin文件中粘贴,将弹出对话框,询问是否将要粘贴的代码转换为Kotlin代码
  • Kotlin文件扩展名为.kt

语法基础

变量声明

  • var - 声明变量
  • val - 声明常量
    val name: String = "Tom"
    var a: Int = 10
    a = 100

数据类型可以省略,Kotlin会自动识别类型

    val name = "Tom"
    var a = 10

基本数据类型

    var f: Float = 0.1f
    var d: Double = 3.14
    var n: Int = 1
    var l: Long = 1000L
    var s: Short = 0xFF
    var b: Byte = 0b1101
    var r: Boolean = false

类型转换

Java中的颜色:

int color = 0xFFFF00FF;

粘贴到Kotlin会被转换为:

val color = -0xff01

如果不转换:

var color: Int = 0xFFFF00FF // 无法编译

可以使用强制转换

var color = 0xFFFF00FF.toInt() // 等同于Java:int color = 0xFFFF00FF;

注意Java的强制转换 (int)a,在Kotlin中需要使用 a.toInt() 。
Java中的 TextView v = (TextView)aView;
Kotlin中需要这样:

var v: TextView = aView as TextView

函数

    fun outputString(text: String) {
        println("$text")
    }

    fun getNumber(): Int {
        return 0
    }

    fun getDate(): Date {
        return Date()   // 创建对象无需使用 new 关键字
    }
    
    fun equal(a: Int, b: Int): Boolean {
        return if (a == b) true else false 
    }

    fun compareFloat(a: Float, b: Float): Int {
        return if (a - b > 0.001) 1 else (if (a - b < -0.001) -1 else 0)
    }

函数必须使用 fun 关键字来修饰。
如果函数需要返回值,则在括号后面添加冒号,然后声明返回值类型。
Kotlin没有三元操作符,可以使用if/else来替代。

命名参数

    fun createStudent(name: String? = null, age: Int = 0, title: String? = null) {
        println("createStudent name:$name age:$age title:$title")
    }

    createStudent("Tom", 10) 
    createStudent(age = 10, name = "Tom") // 跟上一行代码等效

函数需要使用fun关键字修饰,函数的参数声明里面,不可以使用var来修饰,因为Kotlin不允许函数体内修改参数的值。
有默认值的参数,在调用的时候可以省略,使用命名参数方式来调用函数时,参数顺序可以任意调整。因此函数不需要多态,编写一份函数就够灵活使用了。

空指针处理

上面的示例代码中参数类型出现了问号❓,问号的作用是告诉编译器,该变量可以为空。
指针变量默认不可以为空

var name: String = null    // 编译器将报错
var title: String? = null  // 类型后面加上问号,就可以通过编译了
var textView: TextView? = null
fun setColor(textView: TextView, color: Int) {
    textView.setTextColor(color) 
}

函数参数默认不允许为空,因此这里不会出现空指针崩溃,无需做空指针检测

fun setColor(textView: TextView?, color: Int) {
    textView.setTextColor(color)  // 由于name可以为空,不能访问其属性,因此编译会报错
}
fun setColor(textView: TextView?, color: Int) {
    textView?.setTextColor(color)  // 添加问号?,告诉编译器处理为空的情况
}

指针变量加上问号再访问属性方法,其实就是编译器帮我们添加了if/else代码,如果textView为空,就不会去访问textView的setTextColor方法,从而避免空指针崩溃。因此有人说,kotlin不再会出现空指针崩溃。
当然了,有时候,可能你就是希望当指针为空时,抛出异常,而不要掩盖问题原因,那么你可以这样:

fun setColor(textView: TextView?, color: Int) {
    textView!!.setTextColor(color)  // 添加!!,如果textView为空,将抛出空指针异常
}

!! 的作用是告诉编译器,这个指针不允许为空,如果遇到空指针,则直接抛出异常。

class AdwordsView(context: Context, name: String?) : FrameLayout(context) {
    private val TAG = "AdwordsView"
    var textView: TextView
    private var _button: Button

    var name: String? = null  // 可以为空的变量必须声明时赋初值
        set(text) {
            field = text
        }
        get() {
            return field
        }

    init {
        this.name = name
        _button = Button(context)
        textView = TextView(context) // 不能为空的变量必须在初始化函数中赋初值
        textView.setTextColor(0xFFFF0000.toInt())
    }

    // 第二个构造函数
    constructor(context: Context) : this(context, null) {
        println("$TAG  constructor")
    }
}

主构造函数已经内联到class声明中了,它会自动调用init方法,init方法不能显示调用。第二个构造函数其实有些多余,因为主构造函数的name参数其实可以写成 name: String? = null,那么第二个构造函数就没有存在的价值了,当然一旦写了,它就会优先,当我们调用如下代码

var ad = AdwordsView(context)  // 分配对象时,不需要new关键字

该行代码会执行第二个构造函数,删除第二个构造函数则会执行主构造函数。当参数很多的时候,多个构造函数会有一定的用处。

  • 继承关系直接使用冒号即可,不可以多重继承
  • 构造函数一般不编写具体代码,初始化工作应该全部放在init函数去做
  • 类成员变量和函数默认是public的,需要隐藏则添加private关键字
  • setter和getter方法,不必显示编写,编译器会自动生成
  • 初始化函数和构造函数不需要 fun 关键字
  • 一个文件可以定义多个类
class AdwordsView(context: Context, name: String?)

等同于

class AdwordsView constructor(context: Context, name: String?)

主构造函数的constructor可以省略。

既然Kotlin的就是为了减少样板代码量的,那么实现多重接口的意义就不大,回调都可以使用代码block来实现。保留多重接口的支持更多可能是为了Java代码能直接转换为kotlin代码吧,毕竟kotlin的代码形式基本可以全部兼容Java代码。

接口

interface IAdwords {
    fun showAdwords()
}

class AdwordsView(context: Context, name: String? = null) 
    : FrameLayout(context)
    , IAdwords {

    override fun showAdwords() {
        println("showAdwords")
    }
}
  • 如果要实现多重接口,则继续在后面添加,用逗号隔开,最多只能有一个class,其它必须是interface
  • 重载接口中的方法必须使用 override 关键字

回调接口

private var _adwordCallback: IAdwords? = null
fun setAdwordsCallback(adwordCallback: IAdwords?) {
    _adwordCallback = adwordCallback
}

fun onEvent() {
    _adwordCallback?.showAdwords()
}

这种方式的回调接口,基本上跟Java是个相似的。

lambda

Kotlin提供lambda表达式语法来精简代码量,当回调接口只有一个方法的时候,就可以使用这种方式来简化代码:

class Loader {
    private var _onLoadErrorCallback: ((errCode: Int, errMsg: String) -> Unit)? = null
    
    fun setOnLoadErrorCallback(listener: ((errCode: Int, errMsg: String) -> Unit)) {
        _onLoadErrorCallback = listener
    }
    
    fun onErrorEvent() {
        _onLoadErrorCallback?.invoke(501, "Error(501)")
    }
}

var load: Loader()
load.setOnLoadErrorCallback { errCode, errMsg ->
     println("TAG onLoadError errCode: ${errCode} errMsg: ${errMsg}")
}

当我们在Kotlin中调用Java的点击事件监听的时候,就使用了lambda的方式:

fab.setOnClickListener { view ->
    println("TAG onClick:$view")
}

既然Kotlin的就是为了减少样板代码量,那么就不应该再定义有很多方法的接口,尽量将接口拆分到多个接口,一个接口只有一个方法。这样接口的实现方就再也不会出现很多空方法的窘境了,再加上使用协程来处理异步耗时操作,不再异步中定义回调接口,Kotlin的优势才能得到最大发挥。

扩展

fun TextView.textLanguage(): String {
    return if (TextUtils.isEmpty(this.text)) "English" else "[null]"
}

println("${textView.textLanguage()}")
  • 使用扩展可以为现有类添加新的方法
  • 扩展函数内,可以通过 this 访问其属性及方法,但不能访问私有属性和方法

容器

数组

    var nameList = arrayOf("Tom", "Mike", "Steven") // 返回Array对象
    nameList.reverse()
    //nameList.add(); // 长度不可变
    for (name in nameList) {
        Log.w(TAG, "name: ${name}")
    }

Array,数组,长度不可变,但内容可以修改,例如如下:

    var list = arrayOf(1, "Mike", 3.14, 0)
    list.set(0, 0.1)
    list[3] = "100"
    for (value in list) {
        Log.w(TAG, "value: ${value}")
    }

数组的遍历方法:

    val array = Array<Int>(5) { index -> 0 }  // full, verbose syntax
    val arr = Array(5) { 0 }

    for (i in 0..5-1) {
        Log.w(TAG, "array[${i}]: ${array[i]}")
    }

    for (i in arr.indices) {
        Log.w(TAG, "arr[${i}]: ${arr[i]}")
    }

ArrayList,可变,内容和长度都可以修改:

    var arrList = ArrayList<Int>(5)
    for (i in 10 downTo 0 step 2) {
        arrList.add(i)
    }

    for ((index, value) in arrList.withIndex()) {
        println("arrList[$index] is $value")
    }
}

字典

    val map = mapOf("Tom" to 20, "Mike" to 18, "Steven" to 19) // key to value
    println(map)
    println(map["Mike"])
    println(map["George"])

输出结果:

{Tom=20, Mike=18, Steven=19}
18
null

mapOf 返回一个Map<K, V>对象,只读,如果要便捷构造一个可修改的Map,请使用HashMap<K, V>:

    val hashMap = hashMapOf("Tom" to 20, "Mike" to 18, "Steven" to 19)
    hashMap["Steven"] = 15
    hashMap.set("George", 13)
    println(hashMap)

输出结果:

{Mike=18, Tom=20, George=13, Steven=15}

Map的遍历:

    map.forEach {
        println("${it.key} ${it.value}")
    }

还可以这样:

    for ((key, value) in map){
        println("${key} ${value}")
    }

循环控制

    for (i in 0..5) { // 5后面也可以添加step
        print("${i}") 
    }
    // 输出:0 1 2 3 4 5 
    for (i in 10 downTo 0 step 2) {
        print("${i}") 
    }
    // 输出: 10 8 6 4 2 0

头尾都是闭区间。

条件控制

没有三元操作符,但可以用if/else代替

val c = if (condition) a else b

when(强大的switch替代品)

    when (a) {
        1 -> {
            println("a = 1")
        }
        2 -> {
            println("a = 2")
        }
        else -> {
            println("a = $a")
        }
    }

不需要break,多个条件也可以合并到一起:

    when (a) {
        1, 2, 3 -> {
            println("a = $a")
        }
        else -> {
            println("other a = $a")
        }
    }

类型检测

fun testObject(obj: Any) {
    // 类型检测
    if (obj is String) {
        println(obj.length) // 自动类型转换,类型检测后不必再写强制转换代码
    } else {
        println("1. obj isn't a String : " + obj)
    }

    if (obj !is String) {
        println("2. obj isn't a String : " + obj)
    }

    if (obj is String && obj.length > 0) {
        println("3. obj is a String")
    }
}

线程

fun testThread() {
    // 方法一:
    object : Thread() { // kotlin的object 表达式创建匿名类,且重写了run()方法
        override fun run() {
            syncCopyFile("1.jpg")
        }
    }.start()

    // 方法二:
    Thread({ // kotlin中很容易使用lambda表达式,隐藏了对象和方法,直接将代码block传给Thread的构造函数
        syncCopyFile("2.jpg")
    }).start()

    // 方法三:
    var task = thread(start = false){
        syncCopyFile("3.jpg")
    }
    task.start() // 因为将start参数传入了false,因此需要手动start
    
    // 方法四:
    thread(start = true) { // start参数默认就为true,但还是推荐显示声明为true
        syncCopyFile("4.jpg")
    }

    println("testThread End.")
}

@Synchronized fun syncCopyFile(fname: String) {
    println(" %%% Copy File[${fname}] in Thread: ${Thread.currentThread()}")
    sleep(2000)
    println(" %%% Copy File[${fname}] OK.")
}

@Synchronized 为Kotlin的函数同步锁注解,并非关键字。
如果在代码中使用同步锁,那么需要使用 synchronized 函数:

fun syncCopyFile(fname: String) {
    synchronized(this) {
        sleep(2000)
    }
}

如何转到主线程执行代码:

    runOnUiThread {
        textView.setText("Title")
    }

协程

概念

协程 - 轻量级线程
虽然Kotlin中使用线程已经很方便了,但还是推荐使用协程代替线程。
协程主要是让原来要使用“异步+回调方式”写出来的复杂代码, 简化成可以用看似同步的方式写出来(对线程的操作进一步抽象)。 这样我们就可以按串行的思维模型去组织原本分散在不同上下文中的代码逻辑,而不需要去处理复杂的状态同步问题,基本上也不再需要接口处理代码了。

先来看看如下代码:

    fun startCoroutine(name: String) {
        println("  ### 1. Coroutine start in ${Thread.currentThread()}")
        val c1 = GlobalScope.launch(Dispatchers.Default) {
            println("    *** 2. ${name} launch start in ${Thread.currentThread()}")
            delay(1000)
            println("    *** 3. ${name} End of launch in ${Thread.currentThread()}")
        }

        println("  ### 4. Coroutine End. in ${Thread.currentThread()}")
    }


startCoroutine("CO1")

输出结果:

  ### 1. Coroutine start in Thread[main,5,main]
  ### 4. Coroutine End. in Thread[main,5,main]
    *** 2. CO1 launch start in Thread[DefaultDispatcher-worker-1,5,main]
    *** 3. CO1 End of launch in Thread[DefaultDispatcher-worker-3,5,main]

GlobalScope.launch(Dispatchers.Default) 用于启动协程。
从输出结果可以看出,启动协程之前,是在主线程中,但是协程启动后,协程的代码Block是在子线程中执行的。这不是重点,重点在于delay过后,协程的代码一定是在子线程执行的,哪怕launch指定了Unconfined参数,协程一开始将在主线程中执行,但是delay依然不会阻塞主线程,但它的确可以在指定的时间过后返回代码块继续执行后面的代码。这就是delay的强大之处,这个delay是不可以在协程外部的代码中调用的。

协程调度器 功能描述
Dispatchers.Default 运行在 Dispatchers.Default 的线程池中
Dispatchers.Main 运行在主线程中
Dispatchers.IO 运行在 IO 线程中
Dispatchers.Unconfined 运行在当前线程中

PS:之前低版本的那套launch/await 全局函数已经废弃,新版本必须使用GlobalScope.xxx。

协程的作用,就是让开发者感觉是在多线程中工作一样,可以异步处理耗时操作,但实际上可能并没有真正使用线程,而就在同一线程中切换。协程的切换是由编译器来完成的,因而开销很小,并不依赖系统资源,你可以开100000个协程,而无法启动100000个线程。

delay跟线程的sleep很相似,都是延时一段时间,但是不同点在于,delay不会阻塞当前线程,而是挂起协程本身,从而将线程资源释放出来,供其它协程使用。

我们所必须要了解的是,在协程中,当你的耗时任务做完之后,你的代码很可能不在刚才的线程当中,此时必须要注意代码的线程安全问题,例如访问UI,你可以使用runOnUiThread { }。

在startCoroutine的结尾处,可以使用c1.join()来等待协程结束,一旦使用join,编译器便提醒必须添加suspend关键字,该函数也必须在协程中调用。

再来看看修改后的代码:

suspend fun startCoroutine(name: String) {
    println("  ### 1. Coroutine start in ${Thread.currentThread()}")
    val c1 = GlobalScope.launch(Dispatchers.Default) {
        println("    *** 2. ${name} launch start in ${Thread.currentThread()}")
        delay(3000)
        println("    *** 3. ${name} End of launch in ${Thread.currentThread()}")
    }
    c1.join()
    println("  ### 4. Coroutine End. in ${Thread.currentThread()}")
}

该方法因为添加了suspend关键字,因此只能在协程中调用:

    GlobalScope.launch(Dispatchers.Main) {
        startCoroutine("CO1")
    }

输出结果如下:

   ### 1. Coroutine start in Thread[main,5,main]
     *** 2. CO1 launch start in Thread[DefaultDispatcher-worker-2,5,main]
     *** 3. CO1 End of launch in Thread[DefaultDispatcher-worker-3,5,main]
   ### 4. Coroutine End. in Thread[main,5,main]

可以看到,代码中的日志顺序,是按1、2、3、4的顺序输出的了,join函数会等待协程结束。由于我指定了startCoroutine在Dispatchers.Main父协程中运行,因此当join等待子协程完成之后,又回到了主线程执行,这种方式来更新UI的话,都不再需要使用runOnUiThread了,很适合用于做动画。

<span id="jump">协程实战</span>

我们通过一个网络URL加载Web数据的实例,来展示协程对于异步处理的强大之处。
首先,需要在build.gradle中添加:

    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1"

新建一个UrlDownload类:

class UrlDownload {
    // kotlin没有static方法,而是要使用伴生对象来替代
    companion object {
        suspend fun asyncDownload(url: String): String? {
            return GlobalScope.async(Dispatchers.Default) {
                download(url)
            }.await()
        }

        fun download(url: String): String {
            var urlConn : HttpURLConnection? = null
            var strBuffer = StringBuffer()
            var inputStream: InputStream? = null
            var buffer: BufferedReader? = null
            var inputReader: InputStreamReader? = null

            try {
                urlConn = URL(url).openConnection() as HttpURLConnection
                inputStream = urlConn.getInputStream()
                inputReader = InputStreamReader(inputStream)
                buffer = BufferedReader(inputReader)
                do {
                    var line = buffer.readLine()
                    strBuffer.append(line)
                } while (line != null)

            } catch (e: Exception){
                e.printStackTrace()
            } finally {
                inputReader?.close()
                buffer?.close()
                inputStream?.close()
                urlConn?.disconnect()
            }

            return strBuffer.toString()
        }
    }
}

fun startDownload() {
    var url = "https://m.weibo.cn/"
    GlobalScope.launch(Dispatchers.Default) {
        var content = UrlDownload.asyncDownload(url) // 这是一个异步执行的耗时的操作
        println(content)
    }
}

执行以上程序,在主线程调用startDownload()函数,可以看到控制台打印出了网页内容。请注意整个程序没有定义任何回调接口,但结果的确是在业务层打印出来的,阅读代码就好像是同步执行的一样,你也可以看的出,以上代码并不会阻塞主线程。

  • download(url: String)是一个同步方法,实现联网返回网页数据的功能,该方法会阻塞当前线程,不能在主线程调用。
  • asyncDownload方法添加了suspend关键字,说明该函数将被挂起并异步执行,等到异步执行完毕才会返回结果。
  • suspend关键字声明的函数,是一个挂起函数,只能在协程里面调用。
  • 编译器将每一个挂起点的前后作为独立的代码片段,这些代码片段在需要的时候才会执行,不会阻塞当前线程,内部使用状态机来保证协程状态的恢复以及代码片段的顺序执行。
  • 执行了挂起方法之后,无法确定是在哪个线程恢复执行,除非指定了Dispatchers.Main调度器。

如果需要一层一层的往上传递,那么将startDownload做个简单改造即可:

suspend fun startDownload(url: String): String? {
    return GlobalScope.async(Dispatchers.Default) {
        UrlDownload.asyncDownload(url)
    }.await()
}

fun appStartDownload() {
    var url = "https://m.weibo.cn/"
    GlobalScope.launch(Dispatchers.Default) {
        var content = startDownload(url)
        println(content)
    }
}

委托

相信很多同学都写过类似如下代码:

interface IShare {
    void shareText(String text);
    void shareUrl(String url);
    void shareImage(String filePath);
}

class WhatsappShare implements IShare {
    public void shareText(String text) {}
    public void shareUrl(String url) {}
    public void shareImage(String filePath) {}
}

class ShareWrapper {
    public void shareText(String text) {
        _impl.shareText(text)
    }
    public void shareUrl(String url) {
        _impl.shareText(url)
    }
    public void shareImage(String filePath) {
        _impl.shareText(filePath)
    }
}

Kotlin的解决方案:

interface IShare {
    fun shareText(text: String)
    fun shareUrl(url: String)
    fun shareImage(filePath: String)
}

class WhatsappShare : IShare {
    override fun shareText(text: String) {}
    override fun shareUrl(url: String) {}
    override fun shareImage(filePath: String) {}
}

class ShareWrapper(impl: IShare) : IShare by impl {
}

fun main(args: Array<String>) {
    val whats = WhatsappShare()
    ShareWrapper(whats).shareUrl("http://") 
}

一个by关键字就将接口委托给另一个对象处理,而不必编写那些样板代码。

版权声明:本文来源简书,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://www.jianshu.com/p/aad88eaa7d2b
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。
  • 发表于 2020-01-12 13:09:50
  • 阅读 ( 1203 )
  • 分类:

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢