写给 Java 程序员的 Scala 教程

Posted 题材新颖

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了写给 Java 程序员的 Scala 教程相关的知识,希望对你有一定的参考价值。

写给 Java 程序员的 Scala 教程

简介

本篇教程会简单地介绍 Scala 语言及其编译器。本文章适用于具有一定编程经验,同时希望对 Scala 的功能有大概了解的读者。本文假定读者具备面向对象语言,尤其是 Java 编程的基本知识。

最初的尝试

最初的尝试我们将会编写一个 Hello World 程序。这个程序看起来可能没那么高大上,但是它可以让你在学习 Scala 之前先了解一下 Scala 的相关工具怎么用。代码如下:

object HelloWorld 
    def main(args: Array[String]) 
        println("Hello, world!")
    

对于 Java 程序员来说这段代码的结构看起来是比较眼熟的:这段程序主要是由一个 main 函数构成的,这个函数会读取一个命令行参数,也就是由字符串构成的一个数组;main 函数中的主要内容就是对 println 这个函数的调用,这个函数也有一个参数,就是我们写入的 Hello world 这句话。main 函数并没有返回值(这是一个 procedure method),所以我们也不用声明 main 函数的返回类型。

对 Java 程序员来说有点陌生的大概就是开头的 object 关键字。在 Scala 中,这种声明方式意味着我们声明的是一个单例对象,也就是说这个类只会有一个对象。跟在 object 之后,这个程序同时声明了叫做 HelloWorld 类以及这个类的实例,这个实例的名称也是 HelloWorld。这个实例在需要的时候被创建,也就是第一次使用它的时候。

聪明的读者可能已经发现了,main 函数并没有被声明为 static 类型。这是因为在 Scala 中并不存在静态成员。Scala 成员会直接以单例的形式声明成员而不是采用静态成员。

编译示例

要编译示例,我们直接使用 scalac 命令来调用 Scala 编译器(要在命令行下使用)。scalac 和大多数的编译器有着相同的功能:它需要一个源文件作为参数,同时用户也可以自行添加其他参数,最终将会生成一个或者多个目标文件。Scala 编译器生成的目标文件都是标准的 Java class (字节码)文件。

如果我们把上面的示例程序保存到一个名为 HelloWorld.scala 的文件中,我们就可以使用下面的命令来编译它:


scalac HelloWorld.scala

这个命令将会在当前目录下生成多个字节码文件。其中一个名称为 HelloWorld.class,它包含了一个可以被 scala 命令直接使用的类,后面几节中会提到。

运行程序

编译之后, Scala 程序就可以通过 scala 命令来运行。 这个命令的用法和 java 非常相似,并且它们接受相同的参数。上面的示例可以使用下面的命令来运行,这个命令会直接把输出打印到控制台:


scala -classpath . HelloWorld

和 Java 交互

Scala 的一大好处就是它可以很轻松地与 Java 代码交互。java.lang 中的包都会默认导入,而其他的包则需要手动导入。

我们可以通过一个示例来说明一下。这个示例的主要作用是获取当前的时间并且将其格式化为法国的时间表示法.

Java 的类库有很多实用的类,比如 Date 以及 DateFormat 。因为 Scala 可以直接与 Java 进行交互,所以我们不要在 Scala 中重新实现这些类,我们要做的就是直接导入 Java 中的相应类。


import java.util.Date, Locale
import java.text.DateFormat
import java.text.DateFormat._

object FrechDate 

    def main(args: Array[String]) 
        val now = new Date
        val df = getDateInstance(LONG, Locale.FRANCE)
        println(df format now)
    

Scala 的引入语句和 Java 的差不多,但是, Scala 的导入会更加有用。同一个包内的类可以直接把他们放在大括号内一起引入,就像代码中的第一行一样。另外一点不同就是当引入某个包或者类中的全部名称时, Scala 中应当实用下划线而不是星号。这是因为在 Scala 中星号是一个标识符(它可以用于函数名称),之后我们会介绍到这里。

第三行的 import 语句直接导入了 DateFormat 中的全部成员。所以我们在代码中可以直接使用 getDateInstance 以及静态域 LONG

main 函数内部,我们首先创建了 Java 中 Date 类的一个实例,这个对象默认情况下会包含当前的时间。家下来,我们通过 getDateInstance 方法定义了一种时间格式。最后,我们将当前的时间通过 DateFormat 对象格式化之后,打印到控制台。最后一行展示了 Scala 的一种比较有趣的属性,只需要一个参数的方法可以采用中缀的形式调用,也就是这个表达式:


df fromat now

实际上,它就相当于下面这个表达式


df.format(now)

中缀的形式看起来好像省去了不少语法上的细节,但是它有着重要的作用,后面我们会谈到这一点。

本节的主要内容就是与 Java 的整合,需要注意的就是,除去上面的用法,我们也可以直接在 Scala 中继承 Java 的类,或者是直接实现 Java 定义的接口。

万物皆对象

Scala 是一门纯粹的面向对象的语言,也就是说,在 Scala 中万物皆对象,包括数字、函数等。这和 Java 稍微有点不同,因为 Java 会把初始类型(boolean,int等)与引用类型分开,并且不允许把函数当作值来进行修改。

数字也是对象

在 Scala 中,数字也是对象,它们也有自己的方法。实际上,像下面这样的数学表达式:

1 + 2 * 3 / x

是由多个函数调用构成的,它实际上和下面的这个表达式是一样的:

(1).+(((2).*(3))./(x))

这也说明了 +,* 等在 Scala 中都是有效的标识符。

数字周围的括号都是必要的,因为 Scala 的语法分析器采用最长匹配的方式来寻找单词。所以,它会把下面这个表达式分割:

1.+(2)

最终将会分割成1.,+,以及 2. 这三个部分。原因是 1. 相比 1 是一个更长的有效匹配。而 1. 会被解释为字面值 1.0, 也就是一个 Double 类型的数值,这样我们原本想要实现 Int 型操作的效果就无法达成。所以,我们应该写成

(1).+(2)

这样我们就可以防止 1 被解释成 Double 型的值了。

函数也是对象

对于 Java 程序员来说可能会有些吃惊,因为在 Scala 中,函数也是对象。因此我们可以把函数当作参数传递,把它们存储在变量中,甚至可以把它们作为其他函数的返回值。事实上,这种可以直接把函数当作值来进行操作的特性正是函数式 编程的基础。

为了说明为什么把函数当作值来处理在编程中是非常有用的,我们来考虑一下定时器方法,也就是每隔一段时间就会进行某些操作的一种方法。对于一个定时器,我们怎么把它需要进行的操作传递给它?这种传递函数的方式对于很多程序员来说可能并不陌生:他们会直接注册一个回调,这样就可以在某些情况下直接调用自己需要的方法。

在后面的例子中,定时器的程序名称是 oncePerSecond ,并且它接受一个回调函数作为它的参数。这种函数的类型是 ()=>Unit,这个类型适用于所有不需要参数,并且没有返回值的函数(Unit 和 C/C++ 中的 void 类似)。在 main 函数中会调用定时器函数,并传给它一个回调,在回调中会打印一个句子到控制台。换句话说,这个例子会不停的输出 “time flies like an arrow” 这句话


object Timer 

    def oncePerSecond(callback: () => Unit) 
        while (true) callback(); Thread sleep 1000 
    

    def timeFlies() 
        println("time flies like an arrow...")
    

    def main(args: ArrayString]) 
        oncePerSecond(timeFlies)
    


注意,这里为了能够输出字符串,我们使用了预定义的方法 println 来替代 System.out

匿名函数

上面的程序比较容易理解,实际上,它还能够再进一步优化。首先,看一下方法 timeFlies, 它只是为了传递给 oncePerSecond 而定义的。在 Scala 中,哦我们可以使用匿名函数来实现同样的功能,匿名函数就是指,一个没有名称的函数。优化后的版本使用了一个匿名函数来替代 timeFlies


object TimerAnonymous 
    def oncePerSecond(callback: () => Unit) 
        while (true) 
            callback()
            Thread sleep 1000
        
    

    def main(args: Array[String]) 
        oncePerSecond(() => 
            println("time flies like an arrow..."))
    

匿名函数的表达方式就是放在右箭头之后,这个箭头会把函数的参数列表与函数体分开。在这个例子中,参数列表为空,所以用一个空括号放在了箭头的左侧。函数体就和之前是一样的。

正如我们所看到的, Scala 是一个面向对象的语言,所以它也具有类的概念。 Scala 中定义类的语法和 Java 的语法大致相同。主要的不同之处在于, Scala 中的类可以接受参数。下面的例子定义了一个复数类:


class Complex(real: Double, imaginary: Double) 
    def re() = real
    def im() = imaginary

这个复数类接受两个参数,一个代表虚数,一个代表实数。在创建复数类的实例的时候,必须提供这些参数。这个类由两个方法,分别叫 re 和 im,这个两个函数让复数的两个部分可以被外界访问。

有一点值得注意的就是,这两个方法都没有显示地声明返回值类型。实际上编译器会自动推测这些方法的返回类型,它们会扫描程序,比如上面两个方法的右边部分,然后猜测应该会返回 Double 型的数据。

但是编译器并不是任何时候都可以推测具体的类型,因为有些时候编译器没有办法用简单的方式来推测出到底会返回怎样的类型。当然,从实际上来说这并不会成为问题,因为当编译器无法推测出类型的时候它会提示我们。从习惯上来说, Scala 程序员还是应该尽可能避免声明那些容易从上下文中推断出来地类型,然后看一下编译器是否会出问题。一段时间之后,程序员就知道什么时候可以省略类型,什么时候必须声明类型了。

不带参数的函数

对于 reim 来说,有一个小问题,那就是在调用的时候,我们在函数名称之后放一对空括号,就像下面这样:


object ComplexNumbers 
    def main(args: Array[String]) 
        val c = new Complex(1.2, 3.4)
        println("imaginary part: " + c.im())
    

如果我们在访问实数和虚数部分的时候,可以把它们当作域一样访问,也就是不需要放一对空括号,那么代码看起来就更加简洁了。在 Scala 中我们很容易实现这一点,只需要把相应的函数声明为不需要参数的函数就可以了。这些函数和零参函数不同,因为它们在声明的时候就不会带一对括号。因此,我们可以把复数类改写成下面的形式:


class Complex(real: Double, imaginary: Double) 
    def re = real
    def im = imaginary

继承和重写

Scala 中的所有类都继承自一个父类。当没有显式声明一个父类的时候,比如我们的复数类,那么编译器会默认它继承自 scala.AnyRef 类。

在 Scala 中我们可以重写继承自父类的方法。Scala 要求我们使用 override 标识符来显式声明重写关系,这样我们就可以避免在没有重写意图的情况下却不小心重写了父类方法的状况。以复数类为例,我们可以重写它从 Object 类继承的 toString 方法。


class Complex(real: Double, imaginary: Double) 
    def re = real
    def im = imaginary
    override def toString () = 
        "" + re + (if (im < 0) "" else "+") + im + "i"

Case 类以及格式匹配

在程序中经常出现的一种数据结构就是树。举个例子,解释器和编译器通常会将程序表示成树的形式; XML 文档就是树的结构;并且一些容器也是基于树来实现的,比如红黑树。

我们会通过一个小计算器的程序来测试一下在 Scala 中我们如何表示并使用树。这个程序的目的是实现非常简单的运算,它包括求和,整数以及变量。

首先我们要决定怎么表示这些表达式。最自然的方式就是树,节点都是操作符,而叶子都是值。

在 Java 中,这样的树通常由两个部分组成:一个抽象的父类用来表示树,而一个实体子类将会用于表示节点或者叶子。在函数式编程语言中,通常会用代数的数据类型来实现相同功能。Scala 则提供了一种叫做 case classes 概念,这种概念相当于集合了上面提到的两种不同的树的表示方式。下面是我们怎么实现这些类的:


abstract class Tree
case class Sum(l: Tree, r: Tree) extends Tree
case class Var(n: String) extends Tree
case class Const(v: Int) extends Tree

Sum,Var 和 Const 都声明成了 case class , 这也就是说,它们和普通的类有所不同:

  • 对于这些类来说,它们不再需要使用 new 关键字来创建新的实例。
  • 构造参数对应的 getter 将会自动生成。
  • 编译器将会默认生成 equals 以及 hashCode 方法,这些将会作用于实例上。
  • 编译器会提供 toString 方法,并且这个方法会打印“源码信息”
  • 这些类的实例可以通过格式匹配的方式来解构

现在我们已经为我们的数学表达式定义好了类型,接下来就可以开始定义相应的操作了。我们将会从一个在特定环境下计算表达式的函数开始。特定环境的目的是为变量赋值。比如,对于一个表达式 x+1,我们在 x=5 的情况下进行计算,此时我们在代码中写下 x -> 5 ,最后会得到6作为结果。

所以我们需要找到一种方法来表示特定的环境。我们可以使用一些关联性的数据结构,比如哈希表,但是我们也可以直接使用函数。所谓的特定环境,实际上也就是一个函数,这个函数可以把一个值关联给具体的变量。比如 x -> 5 , 在 Scala 中我们就可以这么写:


 case "x" => 5 

这种形式其实定义了一个函数,它获取了一个字符串 “x” 作为参数,然后返回整数5,或者遇到了异常而失败。

在我们编写计算表达式的函数之前,先给环境类型一个名称。我们可以将它的类型表示为 String => Int,不过如果我们为这个类型定一个名称,这样以后在使用的时候就会更加方便:


type Environment = String => Int

从这里开始, Environment 类就可以用作 String 到 Int 类型的一个别称了。

现在我们可以开始定义计算表达式的函数了。实际上,这很简单:两个表达式的和的值,其实就是这些表达式值的和;而一个变量的值会直接从环境中获取;常量的值则是常量本身。在 Scala 中可以很简单地表示:

def eval(t: Tree, env: Environment): Int => t match 
    case Sum(l, r) => eval (l, env) + eval(r, env)
    case Var(n) => env(n)
    case Const(v) => v

这个用于计算的函数,实际上在树t上使用了格式匹配:

  1. 首先,它检查了树t是否是一个 Sum,如果是,那么它就把左侧的子树绑定到了一个新的变量上,这个变量的名称是l,而此时右侧子树则被绑定到r变量上。接下来就按照箭头后面的顺序来进行计算,右侧的表达式可以直接使用左侧的变量。
  2. 如果最初的检查没有成功,也就是说,当前的这棵树并不是 Sum,那么就会检查这棵树是不是 Var,如果是,那么就会将其绑定到含有变量 n 的 Var 类型节点上,然后继续右侧的运算。
  3. 如果第二个检查也失败了,那么就是说这棵树既不是 Sum 也不是 Const,那就会检查这棵树是不是 Const,如果是,那么就将其绑定到一个含有变量 v 的 Const 节点上。
  4. 最后,如果所有的检查都失败了,那么就会抛出一个异常来标识格式匹配的失败,这种情况下一般都是 Tree 类型还有其他的子类型。

从上面的步骤来看,我们可以知道,格式匹配其实就是尝试匹配一系列的格式,一旦找到了合适的格式,那么就把这个变量各个部分的值提取出来,然后用这些值取求最终的结果。

一个对面向对象比较熟的程序员可能会觉得有些奇怪,因为我们并没有把 eval 定义为 Tree 类的一个方法。我们确实可以这么做,因为 Scala 允许我们在 case class 中定义方法,这和普通类并没有什么不同。要使用格式匹配或者是方法都决定于具体的情况,因此我们需要了解一下这两种方式的优缺点:

  • 当我们使用方法的时候,添加新的节点将会非常容易,因为我们只要给 Tree 定义一个子类就可以了。从另一方面来说,要添加一种新的操作则会变得很麻烦,因为我们需要对 Tree 类的所有子类都做出改动。
  • 当我们使用格式匹配的时候,情况则反过来了:添加一种新的节点的时候,所有用到了格式匹配的方法我们都需要进行修改;而当我们添加新的操作的时候,只需要定义一个新的函数即可。

我们将会定义另外一种算数运算来探究一下格式匹配:求导。对这种操作,读者可能还记得下面这些规则:

  1. 两个函数相加的和求导,结果就是分别对两个函数求导再相加
  2. 某个变量v的求导,如果v是一个相关变量,则结果为1,否则为0
  3. 常数的求导结果为零

这些规则可以写成下面这样的 Scala 代码:


def derive(t: Tree, v: String): Tree = t match 
    case Sum(l, r) => Sum(derive(l, v), derive(r, v))
    case Var(n) => if (v == n) => Const(1)
    case _ => Const(0)

这个函数包括了格式匹配的两个概念。首先,针对变量的 case 语句其实是一个 guard,这是 if 之后的表达式。这个 guard 只有在表达式结果为 true 的时候才会被执行。这里的主要作用就是确保只有在求导的变量与当前变量有相同名称的时候才会返回常数1。第二种特性就是宽匹配,也就是 _ ,这个符号可以匹配任何值。

我们并没有讨论到格式匹配的全部特性,但是在这篇文档中不会涉及太多。我们更希望探讨的是,上面的两个函数怎么在实际的操作中起作用。为此,我们要写一个 main 函数来进行相应的计算:


def main(args: Array[String]) 
    val exp: Tree = Sum(Sum(Var("x"), Var("x")), Sum(Const(7), Var("y")))
    val env: Environment =  case "x" => 5 case "y" => 7 
    println("Expression: " + exp)
    println("Evaluation with x=5, y=7: " + eval(exp, env))
    println("Derivative relative to x:\\n" + derive(exp, "x"))
    println("Derivative relative to y:\\n" + derive(exp, "y"))

计算之后,我们会得到想要的输出

Expression: Sum(Sum(Var(x), Var(x)), Sum(Const(7), Var(y)))
Evaluation with x=5, y=7: 24
Derivative relative to x:
    Sum(Sum(Const(1), Const(1)), Sum(Const(0), Const(0)))
Derivative relative to y:
    Sum(Sum(Const(0), Const(0)), Sum(Const(0), Const(1)))

经过测试之后,我们看到求导的结果在展示给读者之前还是需要稍微简化一下。如果定义一个格式匹配的方法的话,应当是一个比较有趣的方式去实现这一点,这个就留给读者自己去实现了。

Traits

除了从父类中继承代码之外, Scala 也可以从一个或者多个 traits 中导入代码。

对于 Java 程序员来说,了解 traits 的最好方法大概就是把它们看作可以包括 代码的接口。在 Scala 中,当一个类继承了一个 trait 后,这个类就实现了那个 trait 中的接口,并且继承了所有包含在那个 trait 中的代码。

为了探索 trait 的作用,我们可以看一个比较经典的例子:有序对象。如果我们要给一系列对象排序,那么如果我们可以直接比较这些对象,排序的过程就会更加轻松。在 Java 中,只要对象实现了 Comparable 接口就可以直接进行比较。在 Scala 中,我们也可以用类似的方式来实现对象的直接比较,但是我们将会是用的是 trait 的形式,这种形式比 Java 中接口的实现要更加好用,我们将会直接实现一个 Ord。

当我们比较对象的时候,有六种不同的关系可能会比较有用:小于,小于或等于,等于,不等,大于或等于,大于。在定义这几种关系的时候要仔细一些,尤其是只要我们定义了其中的两种,剩下的四种都可以用这两种来进行表示。也就是说,比如我们定义了等于以及小于的关系,那么我们就可以表示其他的关系了。在 Scala 中,这些关系可以直接用 Trait 来进行捕获:


trait Ord 
    def <  (that: Any): Boolean
    def <= (that: Any):Boolean = (this < that) || (this == that)
    def >  (that: Any):Boolean = !(this <= that)
    def >= (that: Any): Boolean = !(this < that)

这种形式定义了一个新的类型,它的名字是 Ord,实际上它和 Java 中的 Comparable 接口的作用相同。并且他会用第四种关系来定义其中的三种关系,而第四种关系是一种抽象关系。这种等和不等的关系并没有出现在这里,因为它们在所有的对象中都是默认存在的。

上面用到的 Any 类是 Scala 中所有类的父类。它可以看作是 Java 中的 Object 类。

如果我们想让一个类的实例可以直接比较,像上面的 Ord 类一样定义定义好这些不同的关系就足够了。我们可以定义一个 Date 类来作为一个例子。这种日期由天,月和年来表示,对应的数值都是整数。我们可以先定义 Date 类:


class Date(y: Int, m: Int, d: Int) extends Ord 
    def year = y
    def month = m
    def day = d
    override def toString(): String = year + "-" + month + "-" + day

这部分的代码中比较重要的就是 extends Ord 的声明,它表示 Date 类继承自 Ord trait。

然后,我们重新定义 equals 方法,这个方法继承自 Object, 所以这个方法可以用于比较各个不同的日期对象。编译器默认生成的 equals 方法是无法使用的,因为它和 Java 中默认生成的一样都是直接比较对象的物理地址的,所以我们要重新定义:


override def equals(that: Any): Boolean = 
    that.isInstanceOf[Data] && 
        val o = that.asInstanceOf[Date]
        o.day == day && o.month == month && o.year == year
    

这个方法使用了预定的方法 isInstanceOf 以及 asInstanceOf 。第一个方法, isInstanceOf 对应 Java 中的 instanceOf 操作符,如果这个对象确实是给定类型的实例,那么它就会返回 true。第二个方法, asInstanceOf 对应 Java 中的类型转换:如果这个对象是给定类型的实例,那么它就会被转化为给定类型,否则将抛出 ClassCastExcepion 异常。

最后,我们要定义的方法就是根据优先级来进行比较,就像下面这样。它使用了另外一个预定义的方法,error , 这个方法可以抛出异常并给出错误信息。


def < (that: Any): Boolean = 
    if (!that.isInstanceOf[Date])
        error("cannot compare" + that + " and a Date"

    val o = that.asInstanceOf[Date]
    (year < o.year) ||
    (year == o.year && (month < o.month ||
                       (month == o.month && day < o.day)))

这个函数完成了 Date 类的定义,这个类的实例可以看作是日期对象,也可以看作是可比较的对象。并且,它定义了比较的六种类型,因为它在 Date 类中直接定义了 equal 和 < ,而在 Ord 中继承了其他的比较关系。

泛型

我们将会看到的 Scala 的最后一个特性就是泛型。 Java 程序员对此应该有一定想法,因为在 Java 1.5 中比较大的问题就是没有提供对泛型的支持。

泛型的好处就是我们可以写一段和类型无关的代码。比如,程序员写了一个针对链表的类库,但是问题在于无法知道链表中最终将会存放什么类型的元素。由于链表应用的上下文不同,所以我们没办法直接决定这个链表中到底会分配到什么类型的元素。如果我们直接将元素的类型限制为 Int, 那未免太过武断,并且会导致我们的类库限制太多。

Java 程序员通常会用 Object 类来解决这个问题,因为它是所有类的父类。但是这种解决方式和我们所想要的还是差太远,因为对于基础类型我们没办法这么做,并且这样做程序员需要加入太多动态类型转换。

Scala 则可以让这个过程更加方便,我们可以定义泛型类来解决这个问题。让我们用一个简单的容器类来说明这个问题:这是一个 Reference 类,它可以为空,也可以指向某个类型的对象。


class Reference[T] 
    private var contents: T = _
    def set(value: T) contents = value 
    def get: T = contents

Reference 类由一个类型来参数化,这个类型的名称是 T,也就是相应元素的类型。这种类型在类体中用作 contents 变量的类型,以及 set 方法参数的类型,同时也是 get 方法返回值的类型。

上面的代码实例介绍了 Scala 中的变量,这部分我们不再多说。不过有个部分值得我们一看,那就是我们赋给 contents 变量的初值为 _,这是个默认值,对数字类型来说它是 0,对布尔值来说它是 false,对于 Unit 类型来说它是 (),对所有的对象来说它是 null。

要使用 Reference 类我们需要声明作用于 T 的是什么类型,同时这也是 contents 所对应的类型。打个比方,如果我们要创建并使用一个持有整数类型的单位,那么我们就可以这样:


object IntegerReference 
    def main(args: Array[String]) 
        val cell = new Referencde[Int]
        cell.set(13)
        println("Reference contains the half of " + (cell.get * 2))

就像我们在这个例子中看到的,我们并不需要将 get 方法的函数值转换为整数类型。由于我们已经声明了在这个类型中存放的是整数值,所以我们没有办法将其他类型的值存放进去。

总结

这篇文档大致的讲解了一些 Scala 语言的特性并且给出了一些示例,有兴趣的读者可以继续探究,比如直接看官方文档,其中会含有更多高级的示例,并且可以看看 Scala Language Specification。

以上是关于写给 Java 程序员的 Scala 教程的主要内容,如果未能解决你的问题,请参考以下文章

写给Python程序员的Scala入门教程

教程 | 写给Python程序员的Scala入门教程

写给 Java 程序员的前端 Promise 教程

写给 Java 程序员的前端 Promise 教程

2018 写给开发者的 Kotlin 最完整的视频教程和资源

android进程清理,写给程序员的Flutter详细教程,积累总结