返回首页

Android学习笔记

Android学习笔记

Android学习笔记

四大组件

  1. Activity
  2. Service
  3. BroadcastReceiver
  4. ContentProvider

AndroidMainifest.xml

整个安卓项目的配置文件,程序中定义的所有四大组件都需要在这个文件里面注册,还可以在这个文件中给程序 添加权限声明 路径:项目根目录app/src/main/AndroidMainifest.xml

Kotlin

val, var

声明变量时优先使用 val 在val无法满足需求时才使用 var 声明变量

一行的函数可以简写

fun largerNumber(num1: Int, num2: Int) = max (num1, num2)

if 语句可以存在返回值

val value = if (num1 > num2) {
	num1
} else {
	num2
}

使用when语句替换switch

  1. when 也有返回值
  2. when 允许进行类型匹配
fun checkNumber(num: Number) {
    when(num) {
        is Int -> println("number is Int")
        is Double -> println("number is Double")
        else -> println("number is not support")
    }
}
  1. when的另一种语法
fun getScore2(name: String) = when {
    name.startsWith("Tom") -> 86
    name == "Jim" -> 77
    name == "Jack" -> 95
    name == "Lily" -> 100
    else -> 0
}

区间类型

// 区间[0,10]
val range1 = 0..10
// 左闭右开的区间
val range = 0 until 10
// 实现循环中 i += 2 的效果
for (i in 0 until 10 step 2) {
	println(i)
}
// 实现降序输出[10,1], 注意此处区间两边都是闭合
for (i in 10 downTo 1) {
        println(i)
}

面向对象

kotlin中任何一个非抽象类默认都是不可以被继承的

// 讲类声明为可继承
open class Person {

}
  1. 类的继承
class Student: Person() {

}
  1. 主构造函数和次构造函数
// 主构造函数没有函数体
/**
* 此处age和name都会被认为类的成员变量
*/
class Student(val age: Int, val name: String): Person() {

}

// 提供init方法来给主构造函数添加逻辑
class Student(val sno: String, val grade: Int): Person() {
    init {
    	// 此处使用字符串模板
        println("sno is $sno")
        println("grade is $grade")
    }
}
// 继承时选择调用父类的哪一个构造函数
// 即继承时通过括号来制定调用父类的哪一个构造函数
// 此处为调用父类的无参构造函数
class Student: Person() {

}
/**
*注意,此处name和age没有使用val来声明,一旦使用val或者var声明的参数,
*该参数会成为该类的成员属性
*/
class Student(val sno: String, val grade: Int, name: String, age: Int): Person(name, age) {
    init {
        println("sno is $sno")
        println("grade is $grade")
    }
}
  1. 次构造函数 任何一个类只能有一个主构造函数,但是可以有多个次构造函数.次构造函数也可以 用于构造一个类的实例,只不过它拥有函数体 如果一个类既有主构造函数又有次构造函数时,所有的次构函数都必须调用主构造函数(包括间接调用)
class Student(val sno: String, val grade: Int, name: String, age: Int): 
Person(name, age) {
constructor(name: String, age: Int): this("", 0, name, age){
}
constructor(): this("", 0){ // 此处调用的是上面一个次构造函数
}
}

特殊情况:只有次构造函数没有主构造函数(此时Person后面的括号可以不写) 即有括号代表调用相应的构造函数,没有括号,但是需要在内部使用super调用父类 构造函数

class Student: Person {
constructor(name: String, age: Int): super(name, age) {
}
}
  1. 接口(基本与Java中的一致)
// 此处Study为接口
class Student: Person(), Study {
override fun ...
}

接口中的函数允许存在默认实现

interface Study {
fun readBooks() {
	println("read books")
}
}
  1. 可见性修饰符
privatepublicprotectedinternal
与Java中一致与Java中一致,并且是类的默认选项当前类和子类可见模块内可见
  1. 数据类 自动实现equals, hashCode, toString方法
data class CellPhone(val brand: String, val price: Double) {
}
fun main() {
	val c1 = CellPhone("Samsung", 1299.99)
	val c2 = CellPhone("Samsung", 1299.99)
	println(c1)
	println("c1 equals c2 " + (c1 == c2))
}
  1. 单例类
object Singleton {
	fun singletonTest() {
		println("singletonTest is called")
	}
}
// 调用
Singleton.singletonTest()
  1. 静态方法
// 在写静态方法时,可以使用匿名伴随内部单例类
class Student {
	// 此处添加名字也行,加也行
	// companion object Name{
	companion object {
		fun say() {
		 println("this act like static method")
		}
	}
}

Student.say()

Lambda

  1. 集合的创建 可以使用Java的语法创建
val list = ArrayList<String>()
  • listOf函数:由此创建的列表为不可变列表(只能读取)
val list = listOf("it1", "it2", "it3")
  • mutableListOf函数: 创建可变列表
val list = mutableListOf("it1", "it2", "it3")
  • set也是同list一样创建,set中只会存在不同元素
  • map也存在mafOf函数以及mutableMapOf
val m1 = mutableMapOf<String, String>()
m["key"] = "value"

// 此处to并不是一个关键字而是一个infix函数
val m2 = mapOf("key1" to 1, "key2" to 3)

// 遍历
for((k, v) in m2) {
	println(k + " " + v)
}
  1. Lambda表达式(配合函数式API)
val lst = listOf(1, 2, 3, 4)
// 选出大于5的元素
// 此处这种写法是因为,kotlin中lambda表达式如果为做后一个参数可以将lambda
// 表达式写在外面,如果只有一个参数的话可以省略()
// 由于kotlin的类型推断,可以省略lambda表达式参数的类型
// 只有一个参数时,参数可以用 `it` 来替代
val moreThan5 = lst.maxBy {it > 5}
// 其他api可以自行在ide内查看

空指针检查

kotlin默认所有参数和变量都是非空 要表示可空类型则需要在类型名之后添加问号

// 此处表示num可以为空值但是在编译期就得通过代码逻辑解决空值问题
// 否则编译器会报错
// 如果没有?则一定不为空
fun f(num: Int?)
  • 使用 ?. 判空简化
// 原始判空
if (a != null) {
	a.doSomething()
}
// 使用?.简化判空操作
// 如果a为null则整个表达式的值为null
a?.doSomething()
  • 非空断言 !!
// 此处告诉编译器,content一定不为空
// 但是这存在风险,程序员自信不会发生空指针异常,但还是有可能会出现
content!!.toUpperCase()
  • let函数 let函数会将对象本身作为参数传入函数 使用let函数解决多个 ?. 问题 返回值为let最后一句
// doStudy版本1
// 目前版本仍然比较繁琐
fun doStudy1(study: Study?) {
    study?.readBooks()
    study?.doHomework()
}

//doStudy版本2
fun soStudy2(study: Study?) {
    study?.let {
        it.readBooks()
        it.doHomework()
    }
}

此外let函数可以用来解决全局变量判空问题 if则不行

var study: Study? = null
// 以下代码编译报错
fun doStudy() {
	// 全局可空变量可能在其他线程被修改
	if(study != null) {
		study.readBooks()
		study.doHomework()
	}
}
// 使用let解决全局变量判空问题
var study: Study? = null
fun doStudy() {
	study?.let {
		it.readBooks()
		it.doHomework()
	}
}

小魔术

  • 字符串模板
val num = 10
println("the num is ${num}")
// 单个变量时可以省略{}
// 字符串模板内也可以是有值的表达式
val s1 = "name"
val s2 = "name"
println("${s1.toUpperCase()}")
println("${s1 == s2}")
  • 函数的默认参数
// 使用默认参数
fun printParams(num: Int, str: String = "Hello") {
    println("num is $num str is $str")
}

可以使用键值对的方式调用函数来解决默认参数不匹配问题

printParams(num = 10, str = "world")

Activity

  • 布局xml文件中,引用一个id
"@id/id_name"
  • 创建一个id
"@+id/id_name"
  • 宽高属性
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="按钮"

match_parent 表示当前元素和父元素一样 wrap_content 表示当前元素刚好包裹内容就行 text 按钮上显示的文字

Intent

Intent是Android程序中各组件之间进行交互的一种重要方式,它不仅可以指明当前组件想要执 行的动作,还可以在不同组件之间传递数据。Intent一般可用于启动Activity、启动Service以 及发送广播等场景,由于Service、广播等概念你暂时还未涉及,那么本章我们的目光无疑就锁 定在了启动Activity上面。 Intent 一般有两种

  1. 显式Intent
// 创建Intent实例
// Intent有多个重载的构造函数,其中一个是
Intent(Context packageContext, Class<?> cls)
// packageContext: 启动Activity的上下文
// 	      cls: 想要启动的目标Activity
  1. 隐式Intent 相比于显式Intent,隐式Intent则含蓄了许多,它并不明确指出想要启动哪一个Activity,而是 指定了一系列更为抽象的action和category等信息,然后交由系统去分析这个Intent,并帮 我们找出合适的Activity去启动。 因此,可以通过标签来配置Activity能响应那些Intent
<activity
            android:name=".SecondActivity"
            android:exported="false">
            <intent-filter>
                <action android:name="com.example.activitylearn.ACTION_START"/>
                <category android:name="android.intent.category.DEFAULT"/>
            </intent-filter>
        </activity>

<action> 这个标签指定当前Activity可以响应 com.example.activitylearn.ACTION_START 这个action 而 <category> 标签则包含了 一些附加信息,更精确地指明了当前Activity能够响应的Intent中还可能带有的category。只 有 <action><category> 中的内容同时匹配Intent中指定的action和category时,这个 Activity才能响应该Intent。 修改button的setOnListener

bind.button1.setOnClickListener {
	    // 此处直接传入intent action的标签字符串
            val intent = Intent("com.example.activitylearn.ACTION_START")
            startActivity(intent)
        }
// or
bind.button1.setOnClickListener {
            val intent = Intent("hahahahah")
            startActivity(intent)
        }

由此可以看出,<action> 标签内的 android:name 值可以为自定义字符串只要对应的intent内传入对应的action字符串即可 对于category android.intent.category.DEFAULT 是一种默认的category, 在调用startActivity()方法的时候会自动将这个category添加到Intent中。

  • 每一个Intent只能指定一个action, 但是可以指定多个category
bind.button1.setOnClickListener {
            val intent = Intent("hahahahah")
            intent.addCategory("MY_CATEGORY")
            startActivity(intent)
        }

此时修改 AndroidMainifest.xml 文件 注意,此时DEFAULT仍然要保留,因为调用的是 startActivity 方法,DEFAULT category会被自动加入intent

<intent-filter>
                <action android:name="hahahahah"/>
                <category android:name="android.intent.category.DEFAULT"/>
                <category android:name="MY_CATEGORY"/>
            </intent-filter>
  • 更多隐式Intent 使用隐式Intent,不仅可以启动自己程序内的Activity,还可以启动其他程序的Activity,这就 使多个应用程序之间的功能共享成为了可能。 浏览器打开网页
val intent = Intent(Intent.VIEW)
intent.data = Uri.Parse("https://www.baidu.com")
  • <data> 标签 在 <intent-filter> 标签中再配置一个 <data> 标签,用于更精确地指 定当前Activity能够响应的数据。<data> 标签中主要可以配置以下内容。
  • android:shceme 用于定义数据的协议部分,如https
  • android:host 用于指定数据的主机名部分,如www.baidu.com
  • android:port 用于指定数据的端口
  • android:path 用于制定主机名和端口之后的部分
  • android:mimeType 用于指定可以处理的数据类型,允许使用通配符的方式指定
  1. 使用Intent向下一个Activity传递数据 使用Intent的putExtra方法
val data = "Hello SecondActivity"
val intent = Intent(this, SecondActivity::class.java)
intent.putExtra("extra_data", data)
startActivity(intent)

在第二个Activity中获取Extra数据

class SecondActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivitySecondBinding.inflate(layoutInflater)
        setContentView(bind.root)
        val extraData = intent.getStringExtra("extra_data")
        Log.d("SecondActivity", "Extra data is $extraData")
    }
}
  • 返回结果给上一个Activity 并没有一个用于启动的Activity的Intent来传递数据,可以使用 startActivityForResult 方法在Activity在销毁时返回数据给上一个Activity(注意此方法已经被弃用) 可以使用AndroidXActivity 和 Fragment中引入的ActivityResult API
bind.button2.setOnClickListener {
            val intent = Intent()
            intent.putExtra("data_return", "Hello FirstActivity")
	    // 专门用来向上一个Activity传递数据
            setResult(RESULT_OK, intent)
            finish()
        }

在 SecondActivity被销毁之后会回调上一个Activity的onActivityResult()方法,因此我们 需要在FirstActivity中重写这个方法来得到返回的数据

  • 保存上一个Activity的数据 使用 onSaveInstanceState 方法保证Activity被回收之前一定会被调用
// 在要被收回的Activity中重写 onSaceInstanceState 方法
override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        val tempData = "Something you just typed"
        outState.putString("data_key", tempData)
    }

OnCreate 方法参数的 savedInstanceState 可以获得保存的数据

Activity 生命周期

flowchart TD
A([启动Activity]) --> B(["onCreate()"])
subgraph 完整生存期
direction LR
B --> C(["onStart()"])
subgraph 可见生存期
C --> D(["onResume()"])
subgraph 前台生存期
D --> E([Activity运行中])
E -- 另一个Activity来到前台 --> F(["onPause()"])
end
F -- Activity不再可见 --> G(["onStop()"])
end
G -- Activity被销毁 --> H(["onDestory()"])
end
H --> L([关闭Activity])
G -- 返回上一个Activity ----> I(["onRestart()"])
I ----> C
F -- 返回上一个Activity --> D
G -- 另一个优先级更高的<br/>程序需要内存 ----> K([杀掉进程])
F -- 另一个优先级更高的<br/>程序需要内存 --> K
K -- 返回上一个Activity --> B

4. Activity的启动模式 在实际项目中我们应该根据特定的需求为每个Activity指定恰当的启动模式。启动模式一共有4种, 分别是standard、singleTop、singleTask和singleInstance,可以在AndroidManifest.xml中通过给 <activity> 标签指定 android:launchMode 属性来选择启动模式

  • standard standard是Activity默认的启动模式,在不进行显式指定的情况下,所有Activity都会自动使用这种启动模式

使用standard模式的Activity,系统不会在乎这个Activity是否已经在返回栈中存在, 每次启动都会创建一个该Activity的新实例 示例 在自身存在的按钮上再次创建自身Activity实例

// 此Activity为MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
        val bind = ActivityMainBinding.inflate(layoutInflater)
        super.onCreate(savedInstanceState)
        Log.d(tag, "onCreate")
        setContentView(bind.root)
        bind.startNormalActivity.setOnClickListener {
            val intent = Intent(this, MainActivity::class.java)
	    // 再次启动自身实例
            startActivity(intent)
        }
}

standard模式原理图

flowchart TD
s1([MainActivity]) -- 返回 --> s2([MainActivity])
s2 -- 返回 --> s3([MainActivity])
s3 -- 创建 --> s2
s2 -- 创建 --> s1
返回栈
* *singleTop* 当Activity的启动模式指 定为singleTop,在启动Activity时如果发现返回栈的栈顶已经是该Activity,则认为可以直接 使用它,不会再创建新的Activity实例。但是当当前该Activity未处于栈顶时仍然会创建多个实例 通过修改 `AndroidMainifest.xml` 修改启动模式
<activity
            android:name=".FirstActivity"
            android:launchMode="singleTop"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

注意,此处在 <activity> 标签内添加 android:launchMode 属性来设置启动模式 FirstActivity 和 SecondActivity 中按钮的监听事件

// FirstActivity
bind.btn1.setOnClickListener {
            val intent = Intent(this, SecondActivity::class.java)
            startActivity(intent)
        }
// SecondActivity
bind.btn2.setOnClickListener {
            val intent = Intent(this, FirstActivity::class.java)
            startActivity(intent)
        }

singleTop原理图

flowchart
s1([FirstActivity]) -- 返回 --> s2([SecondActivity])
s2 -- 返回 --> s3([FirstActivity])
s3 -- 启动新Activity --> s2
s2 -- 检查栈顶判断<br/>是否需要启动<br/>新的Activity --> s1
返回栈
* _singleTask_ 当Activity的启动模式指定为singleTask,每次启动该Activity时, 系统首先会在返回栈中检查是否存在该Activity的实例,如果发现已经存在则直接使用该实例, 并把在这个Activity之上的所有其他Activity统统出栈,如果没有发现就会创建一个新的 Activity实例。 通过修改 `AndroidMainifest.xml` 修改启动模式
<activity
            android:name=".FirstActivity"
            android:launchMode="singleTask"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

FirstActivity 和 SecondActivity 代码和之前相同在FirstActivity的onRestart方法和SecondActivity的onDestory方法中添加日志输出

// FirstActivity
override fun onRestart() {
        super.onRestart()
        Log.d("FirstActivity", "OnRestart")
    }
// SecondActivity
override fun onDestroy() {
        super.onDestroy()
        Log.d("SecondActivity", "onDestroy")
    }

在SecondActivity中启动FirstActivity时,会发现返回栈 中已经存在一个FirstActivity的实例,并且是在SecondActivity的下面,于是SecondActivity 会从返回栈中出栈,而FirstActivity重新成为了栈顶Activity,因此FirstActivity的 onRestart()方法和SecondActivity的onDestroy()方法会得到执行。现在返回栈中只剩下 一个FirstActivity的实例了,按一下Back键就可以退出程序。 singleTask原理图

flowchart BT
s2([SecondActivity]) --> s1([直接出栈重新启动FirstActivity])
subgraph 返回栈
direction BT
s3([FirstActivity]) -- 启动SecondActivity --> s2
end
返回栈
* *singInstance* 不同于以上3种启动模式,指定为singleInstance模式的Activity会启用一个新 的返回栈来管理这个Activity(默认情况下singleTask只是用一个返回栈来管理 Activity, 如果singleTask模式指定了不同的taskAffinity,也会启动 一个新的返回栈) 此种启动模式的一种应用为解决共享Activity实例 如果想实现其他程序和我们的程序可以共享这个Activity的实 例,应该如何实现呢?使用前面3种启动模式肯定是做不到的,因为每个应用程序都会有自己的 返回栈,***同一个Activity在不同的返回栈中入栈时必然创建了新的实例***。而使用 singleInstance模式就可以解决这个问题,在这种模式下,会有一个单独的返回栈来管理这个 Activity,不管是哪个应用程序来访问这个Activity,都共用同一个返回栈,也就解决了共享 Activity实例的问题 通过修改 `AndroidMainifest.xml` 修改启动模式
<activity
            android:name=".FirstActivity"
            android:launchMode="singleInstance"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

按下Back键进行返回,ThirdActivity直接返回到了FirstActivity,再按 下Back键又会返回到SecondActivity,再按下Back键才会退出程序 由于FirstActivity和ThirdActivity是存放在同一个返回栈里的,当在ThirdActivity 的界面按下Back键时,ThirdActivity会从返回栈中出栈,那么FirstActivity就成为了栈顶 Activity显示在界面上,因此也就出现了从ThirdActivity直接返回到FirstActivity的情况。然后 **在FirstActivity界面再次按下Back键,这时当前的返回栈已经空了,于是就显示了另一个返回 栈的栈顶Activity,即SecondActivity。**最后再次按下Back键,这时所有返回栈都已经空了, 也就自然退出了程序 singleInstance原理图

flowchart LR
subgraph 返回栈B
direction BT
s2([SecondActivity])
end
subgraph 返回栈A
direction TB
s3([ThirdActivity])
s1([FirstActivity])
end
s3 -- 返回 --> s1
s1 -- 启动新Activity --> s2
s1 -- 返回 --> s2
s2 -- 重新启动Activity --> s3

Activity的最佳实践

知晓当前是在哪一个Activity

自己写一个Activity继承自AppCompatActivity,在此Activity的OnCreate方法中添加答应当前对象的Class名称的日志输出, 最后让项目中所有Activity都集成自这个Activity

// 自己写的单独的Activity
open class BaseActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
	// 此处javaClass获取的是当前实例对象
        Log.d("BaseActivity", javaClass.simpleName)
    }
}

// 其他Activity继承自BaseActivity
class MainActivity : BaseActivity() {
}

此时程序运行分别进入不通道Activity时,就会输出对应的Activity类名信息

随时随地退出程序

使用一个集合对所有Activity进行管理

object ActivityCollector {
    private val activities = ArrayList<Activity>()

    fun addActivity(activity: Activity) {
        activities.add(activity)
    }

    fun removeActivity(activity: Activity) {
        activities.remove(activity)
    }

    fun finishAll() {
        for (activity in activities) if (!activity.isFinishing) activity.finish()
        activities.clear()
    }
}

在其他Activity中的onCreate方法中将自身Activity加入到该集合中 在onDestroy中移除

class MainActivity : BaseActivity() {

    private val tag = "MainActivity"

    override fun onCreate(savedInstanceState: Bundle?) {
    // ...
        ActivityCollector.addActivity(this)
	// ...
	}
	// ...
    override fun onDestroy() {
        super.onDestroy()
        ActivityCollector.removeActivity(this)
    }
}

从此以后,不管你想在什么地方退出程序,只需要调用ActivityCollector.finishAll() 方法就可以了。 但是此时APP还是在后台运行,可以使用killProcess杀掉后台进程

android.os.Process.killProcess(android.os.Process.myPid())

启动Activity的最佳写法

通过添加 compaion object,添加对应的方法,来解决启动参数传递问题

class SecondActivity: BaseActivity {
	companion object {
		fun actionStart(context: Context, data1: String, data2: String) {
			val intent = Intent(context, SecondActivity::class.java)
			intent.putExtra("param1", data1)
			intent.putExtra("param1", data2)
			context.startActivity(intent)
		}
	}
}

标准函数和静态方法

标准函数with, run和apply

  • with with函数接收两个参数:第一个参数可以是一个任意类型的对 象,第二个参数是一个Lambda表达式。with函数会在Lambda表达式中提供第一个参数对象 的上下文,并使用Lambda表达式中的最后一行代码作为返回值返回。
val result = with(obj) {
	// obj上下文
	"value" // with函数的返回值
}

作用: 它可以在连续调用同一个对象的多个方法时让代码变得更加精简

// 不使用with的代码
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
val builder = StringBuilder()
builder.append("Start eating fruits\n")
for (fruit in list) {
	builder.append(fruit).append("\n")
}
builder.append("Ate all fruits")
val result = builder.toString()
println(result)

以上代码多次使用builder对象,可以使用with来简化

val result1 = with(StringBuilder()) {
       append("Start eating fruits\n")
       for (fruit in list) {
           builder.append(fruit).append("\n")
       }
       append("Ate all fruits")
       toString()
   }
    println(result1)
注意,此处for循环如果list已经经历过一次for循环,则list为空
* run run函数的用法和使用场景其实和 with函数是非常类似的,只是稍微做了一些语法改动而已。首先run函数通常不会直接调用, 而是要在某个对象的基础上调用;其次run函数只接收一个Lambda参数,并且会在Lambda表 达式中提供调用对象的上下文
val result = obj.run {
	// obj 上下文
	"value" // run函数的返回值
}

对上面使用with函数的代码进行改写

val result2 = builder.run {
        append("Start eating fruits\n")
       for (fruit in list) {
           builder.append(fruit).append("\n")
       }
       append("Ate all fruits")
       toString()
    }
    println(result2)
  • apply apply函数和run函数也是极其类似的,都要在某 个对象上调用,并且只接收一个Lambda参数,也会在Lambda表达式中提供调用对象的上下 文,但是apply函数无法指定返回值,而是会自动返回调用对象本身
val result = obj.apply {
	// obj的上写文
}
// result == obj

使用apply改写上面使用run函数的代码

val result3 = StringBuilder().apply {
        append("Start eating fruits\n")
       for (fruit in list) {
           builder.append(fruit).append("\n")
       }
       append("Ate all fruits")
    }
    println(result3.toString())

定义静态方法

  1. 使用 companion object 来给类添加 前文Kotlin部分已经提过,此处就不在赘述
  2. 注解 给单例类或companion object中的方 法加上@JvmStatic注解,那么Kotlin编译器就会将这些方法编译成真正的静态方法 注意,@JvmStatic注解只能加在单例类或companion object中的方法上,如果你尝试加在 一个普通方法上,会直接提示语法错误
  3. 顶层方法 顶层方法指的是那些没有定义在任何类中的方法, Kotlin编译器会将所有的顶层方法全部编译成静态方法
// 顶层方法,Kotlin自动编译为静态方法
fun doSomething() {
    println("doSomething")
}
所有的顶层方法都可以在任何位置被直接调
用,不用管包名路径,也不用创建实例

在Kotlin中所有的顶层方法都可以在任何位置被直接调,不用管包名路径,也不用创建实例 在Java中Kotlin编译器会创建名称为文件名后跟Kt的java类,可以使用这个类来调用静态方法

UI

几种基础控件

TextView

android:layout_widthandroid:layout_height 指定了控件的宽度和高度。

Android中所有的控件都具有这两个属 性,可选值有3种:match_parentwrap_content和固定值。match_parent表示让当前 控件的大小和父布局的大小一样,也就是由父布局来决定当前控件的大小。wrap_content表 示让当前控件的大小能够刚好包含住里面的内容,也就是由控件内容决定当前控件的大小。固 定值表示表示给控件指定一个固定的尺寸,单位一般用dp,这是一种屏幕密度无关的尺寸单 位,可以保证在不同分辨率的手机上显示效果尽可能地一致,如50 dp就是一个有效的固定值 TextView中的文字默认是居左上角对齐的

使用android:gravity来指定文字的对齐方式

可选值有top、bottom、start、 end、center等,可以用“|”来同时指定多个值,这里我们指定的是"center",效果等同 于"center_vertical|center_horizontal",表示文字在垂直和水平方向都居中对齐

注意`android:gravity` 属性是针对内部的文字而言如果本身例如宽度设为 `wrap_content` 文字比较少的话,再设置水平属性会无效或者说效果是没有效果,垂直属性也是如此
通过 `android:textColor` 属性可以指定文字的颜色,通过android:textSize属性可以指定 文字的大小。文字大小要使用sp作为单位,这样当用户在系统中修改了文字显示尺寸时,应用 程序中的文字大小也会跟着变化。
<TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="Hello World!"
        android:gravity="center"
        android:textColor="#00ff00"
        android:textSize="24sp"
    />

Button

Android系统默认会将按钮上的英文字母全部转换成大写

<Button
        android:id="@+id/btn1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Button"
        />

可以在XML中添加 android:textAllCaps="false" 这个属性,这样系统就会保留你指定的原始文字内容了

注册监听事件
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
	// 
	/**
	* 使用viewBinding 需要在app文件夹下的build.gradle内添加如下代码
	* buildFeatures {
	*	viewBinding true
	* }
	*
	*
	*/
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)

        bind.btn1.setOnClickListener {
            // 添加逻辑
        }
    }
}

EditText

<EditText
        android:id="@+id/editText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        />
  1. android:hint 给EditText添加提示性文本
  2. android:maxLines 指定最大行数
注意:这里通过android:maxLines指定了EditText的最大行数为两行,这样当输入的内容超过两行 时,文本就会向上滚动,EditText则不会再继续拉伸
3. 获取editText的文本并显示
bind.btn1.setOnClickListener {
            // 添加逻辑
            val res = bind.editText.text.toString()
            Toast.makeText(this, res, Toast.LENGTH_SHORT).show()
        }

这里调用的实际是getText方法,这是一个Kotlin语法糖, 同理设置文本内容也可直接赋值, 实际调用的是setText方法 但是得使用实现了Editable接口的类

bind.btn1.setOnClickListener {
            // 添加逻辑
            val res = bind.editText.text.toString()
            Toast.makeText(this, res, Toast.LENGTH_SHORT).show()
            bind.editText.text = SpannableStringBuilder().append("Text")
        }

ImageView

在res文件夹下建立drawable-xxhdpi因为当下很多设备的分辨率大多是xxhdpi,在这个文件夹下添加图片文件(文件名不能以数字开头) android:src 用来制定图片路径

<ImageView
	android:id="@+id/imgid"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/imag1"
        />

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/imag2"
        />

使用代码动态修改图片

bind.imgid.setImageResource(R.drawable.imag2)

注意此处imgid是设置的标签id

ProcessBar

<ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        />
  • 可见性设定
  1. 使用xml设定 使用 android:visibility 设定,属性值可以为 visible, invisible, gone 控件默认是 visible invisible 表示控件不可见,但是它仍然占据着原来的位置和大小,可以理解成控件变成透明状态了 gone 则表示控件不仅不可见,而且不再占用任何屏幕空间
  2. 使用代码设定
// 方法允许传入View.VISIBLE, View.INVISIBLE, 和 View.GONE 三个值
setVisibility()
// 按钮点击来回切换加载条可见性
if (bind.progressBar.visibility == View.VISIBLE) {
                bind.progressBar.visibility = View.GONE
            } else {
                bind.progressBar.visibility = View.VISIBLE
            }
  1. 修改属性
  • 修改style属性, 修改样式
<ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        style="?android:attr/progressBarStyleHorizontal"
	android:max="100"
        />

注意此处属性写法与之前的会有些不同,此处的progressBarStyleHorizontal 对应的代码位于AndroidSdk目录下IDE内可以查看路径

  • 通过代码动态改动进度条
bind.progressBar.progress += 10

AlertDialog

弹出警告框, 在代码内实现

AlertDialog.Builder(this).apply {
                setTitle("This is a Dialog")
                setMessage("Something important")
                setCancelable(false)
                setPositiveButton("OK") {dialog, which -> }
                setNegativeButton("Cancel") {dialog, which -> }
                show()
            }

3种基本布局

LinearLayout (线性布局)

可以使用 android:orientation 属性来设置方向 vertical 表示垂直, horizontal 表示水平

  • android:layout_gravity 在布局内的组件使用 android:layout_gravity 用于指定控件在布局中的 对齐方式。android:layout_gravity 的可选值和 android:gravity 差不多,但是需要注 意,**当LinearLayout的排列方向是horizontal时,只有垂直方向上的对齐方式才会生效。**因 为此时水平方向上的长度是不固定的,每添加一个控件,水平方向上的长度都会改变,因而无 法指定该方向上的对齐方式。同样的道理,当LinearLayout的排列方向是vertical时,只有 水平方向上的对齐方式才会生效
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="top"
        android:text="button1"
        />

    <Button
        android:id="@+id/btn2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="button2"
        android:layout_gravity="center_vertical"
        />

    <Button
        android:id="@+id/btn3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="button3"
        android:layout_gravity="bottom"
        />
</LinearLayout>
  • android:layout_weight (权重) 类似css中的grid布局或者flow布局 这个属性允许我们使用比例的方式来指定控件的大小,它在手机屏幕的适配性方面可以起到非常重要的作用
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn3"
        android:layout_width="0dp"
        android:layout_weight="1"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"
        />

    <EditText
        android:id="@+id/input_msg"
        android:layout_width="0dp"
        android:layout_weight="1"
        android:layout_height="wrap_content"
        android:hint="Type Something"
        />

</LinearLayout>

使用了 android:layout_weight 属性,此时控件的宽度就不应该再由 android:layout_width 来决定了,这里指定成0 dp是一种比较规范的写法 在仅指定了EditText的 android:layout_weight 属性,并将Button的宽度改回了 wrap_content 。这表示Button的宽度仍然按照 wrap_content 来计算,而EditText则会占满 屏幕所有的剩余空间。

注意,此处是除了Button所占空间以外的剩余空间
<Button
        android:id="@+id/btn3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"
        />

    <EditText
        android:id="@+id/input_msg"
        android:layout_width="0dp"
        android:layout_weight="1"
        android:layout_height="wrap_content"
        android:hint="Type Something"
        />

RelativeLayout (相对布局)

和LinearLayout的排列规则不同,RelativeLayout显得更加随意,它可以通过相对定位的方式让控件出现在布局的任何位置。

相对于父组件进行定位
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"

        android:layout_alignParentLeft="true"
        android:layout_alignParentTop="true"
        />

    <Button
        android:id="@+id/btn2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"

        android:layout_alignParentRight="true"
        android:layout_alignParentTop="true"
        />
    <Button
        android:id="@+id/btn3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"

        android:layout_centerInParent="true"
        />
    <Button
        android:id="@+id/btn4"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"

        android:layout_alignParentLeft="true"
        android:layout_alignParentBottom="true"
        />
    <Button
        android:id="@+id/btn5"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"

        android:layout_alignParentRight="true"
        android:layout_alignParentBottom="true"
        />

</RelativeLayout>

此处的布局为5个按钮分别想对于父组件处于左上,右上,中间,左下,右下位置

想对于控件进行定位
<Button
        android:id="@+id/btn3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"

        android:layout_centerInParent="true"
        />


    <Button
        android:id="@+id/btn1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"

        android:layout_above="@id/btn3"
        android:layout_toLeftOf="@id/btn3"
        />


    <Button
        android:id="@+id/btn2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"

        android:layout_above="@id/btn3"
        android:layout_toRightOf="@id/btn3"
        />


    <Button
        android:id="@+id/btn4"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"

        android:layout_below="@id/btn3"
        android:layout_toLeftOf="@id/btn3"
        />


    <Button
        android:id="@+id/btn5"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"

        android:layout_toRightOf="@id/btn3"
        android:layout_below="@id/btn3"
        />

android:layout_alignLeft 表示让一个控件的左边缘和另一个控件的左边缘对齐,android:layout_alignRight 表示 让一个控件的右边缘和另一个控件的右边缘对齐。此外,还有 android:layout_alignTopandroid:layout_alignBottom ,道理都是一样的

FrameLayout (帧布局)

应用场景少,所有控件默认摆放在左上角

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"
        android:textAllCaps="false"

        android:layout_centerInParent="true"
        />


    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="This is a text"
        />

</FrameLayout

创建自定义控件

引入布局

我们所用的所有控件都是直接或间接继承自View的,所用的所有布局都是直接或间接继承自ViewGroup的 在layout文件夹下创建title.xml文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <Button
        android:id="@+id/titleBack"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="5dp"
        android:text="Back"
        android:textColor="#ff0000"
        />

    <TextView
        android:id="@+id/titleText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_weight="1"
        android:gravity="center"
        android:text="Title Text"
        android:textColor="#00ff00"
        android:textSize="24sp"
        />

    <Button
        android:id="@+id/titleEdit"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="5dp"
        android:text="Back"
        android:textColor="#0000ff"
        />
</LinearLayout>
注意此处LinearLayout没有设置`android:orientation` 属性,默认水平放置组件, height设置为wrap_content 则刚好包住Button
在activity_main.xml文件内添加include标签
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <include layout="@layout/title" />

</LinearLayout>

通过getSupportBar方法获取默认标题栏,通过hide方法隐藏默认标题栏

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)
        supportActionBar?.hide()
    }
}

创建自定义控件

新建TitleLayout继承自LinearLayout,让它成为我们自定义的标题栏控件,最后可以通过包名在xml文件中作为对应标签

class TitleLayout(context: Context, attrs: AttributeSet):
    LinearLayout(context, attrs) {
   init {
       LayoutInflater.from(context).inflate(R.layout.title, this)
   }
}

通过LayoutInflater的from()方法可以构建出一个LayoutInflater对象,然后调用inflate()方法就可以动态加载一个布局文件。 inflate()方法接收两个参数:第一个参数是要加载的布局文件的id,这里我们传入 R.layout.title;第二个参数是给加载好的布局再添加一个父布局,这里我们想要指定为 TitleLayout,于是直接传入this 在activity_main.xml文件中如下

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.example.uicustomview.TitleLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

</LinearLayout>
class TitleLayout(context: Context, attrs: AttributeSet):
    LinearLayout(context, attrs) {
   init {
    val b = LayoutInflater.from(context).inflate(R.layout.title, this)
    val bind = TitleBinding.bind(b)
    bind.titleBack.setOnClickListener {
        val activity = context as Activity
        activity.finish()
    }
    bind.titleEdit.setOnClickListener {
        Toast.makeText(context,
            "You clicked Edit Button", Toast.LENGTH_LONG).show()
    }
   }
}

注意,此处context为Activity类型,并且为父Activity,此时即为MainActivity,因为在xml中,activity_main.xml文件包含了TitleLayout标签, Kotlin使用 as 进行强制类型转换

ListView

简单使用

activity_main.xml 文件内容

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <ListView
        android:id="@+id/listView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</LinearLayout>

对应kotlin代码

private val data = listOf("Apple", "Banana", "Orange", "Watermelon",
        "Pear", "Grape", "Pineapple", "Strawberry", "Cherry", "Mango",
        "Apple", "Banana", "Orange", "Watermelon", "Pear", "Grape",
        "Pineapple", "Strawberry", "Cherry", "Mango")

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)

        val adapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, data)
        bind.listView.adapter = adapter
    }

定制ListView界面

自定义Adapter,通过继承重写getView方法FruitAdapter定义了一个主构造函数,用于将Activity的实例、ListView子项布局的id和数 据源传递进来。另外又重写了getView()方法,这个方法在每个子项被滚动到屏幕内的时候会 被调用。

class Fruit(val name: String, val imageId: Int)

class FruitAdapter(activity: Activity, private val resourceId: Int, data: List<Fruit>):
        ArrayAdapter<Fruit>(activity, resourceId, data) {
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        val view = LayoutInflater.from(context).inflate(resourceId, parent, false)
        val bind = FruitItemBinding.bind(view)
        val fruit = getItem(position)
        val fruitImage = bind.fruitImage
        val fruitName = bind.fruitName
        if (fruit != null) {
            fruitImage.setImageResource(fruit.imageId)
            fruitName.text = fruit.name
        }
        return view
    }
}

提升ListView的效率

在FruitAdapter的getView()方法中,每次都将布局重新加载了一遍,当ListView快速滚动的时候,这就会成为性能的瓶颈 getView()方法中还有一个convertView参数,这个参数用于将之前加载好的布局进行缓存, 以便之后进行重用,我们可以借助这个参数来进行性能优化。

override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        val view: View =
            convertView ?: LayoutInflater.from(context).inflate(resourceId, parent, false)
        val bind = FruitItemBinding.bind(view)
        val fruit = getItem(position)
        val fruitImage = bind.fruitImage
        val fruitName = bind.fruitName
        if (fruit != null) {
            fruitImage.setImageResource(fruit.imageId)
            fruitName.text = fruit.name
        }
        return view
    }

使用ViewHolder进行优化

override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        val view: View
        val viewHolder: ViewHolder
        if (convertView == null) {
            view = LayoutInflater.from(context).inflate(resourceId, parent, false)
            val bind = FruitItemBinding.bind(view)
            viewHolder = ViewHolder(bind.fruitImage, bind.fruitName)
            view.tag = viewHolder
        } else {
            view = convertView
            viewHolder = view.tag as ViewHolder
        }
        val fruit = getItem(position)
        if (fruit != null) {
            viewHolder.fruitImage.setImageResource(fruit.imageId)
            viewHolder.fruitName.text = fruit.name
        }
        return view
    }

新增了一个内部类ViewHolder,用于对ImageView和TextView的控件实例进行缓存, Kotlin中使用inner class关键字来定义内部类 ViewHolder 实际是使用view中存在tag属性(此属性是Object类型),用ViewHolder将fruitImage和 fruitName进行封装,再将ViewHolder赋值给tag属性,再利用converView这个参数存储的是之前加载好的布局,由此,第一次调用getView时,convertView为空,则将布局加载出来,并将对应的ViewHolder赋值给tag,之后每次调用getView,如果convertView不为空,及次view已经被加载过,那直接将tag强制类型转换成ViewHolder直接使用即可

ListView的点击事件

给ListView添加能够响应用户的点击事件

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)
        initFruits()
        val adapter = FruitAdapter(this, R.layout.fruit_item, fruitList)
        bind.listView.adapter = adapter
        bind.listView.setOnItemClickListener { parent, view, position, id ->
            val fruit = fruitList[position]
            Toast.makeText(this, fruit.name, Toast.LENGTH_LONG).show()
        }
    }

直接使用setOnItemClickListener方法 当然Kotlin也提供省略参数的写法

bind.listView.setOnItemClickListener { _, _, position, _ ->
            val fruit = fruitList[position]
            Toast.makeText(this, fruit.name, Toast.LENGTH_LONG).show()
        }

RecyclerView (增强版ListView)

基本用法

因为RecyclerView定义在AndroidX当中所以首先需要在app/build.gradle中添加依赖

implementation 'androidx.recyclerview:recyclerview:1.2.1'

在xml布局文件中添加如下代码

<androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        />
class FruitAdapter(private val fruitList: List<Fruit>):
        RecyclerView.Adapter<FruitAdapter.ViewHolder>() {
           inner class ViewHolder(view: View): RecyclerView.ViewHolder(view) {
               val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
               val fruitName: TextView = view.findViewById(R.id.fruitName)
           }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.fruit_item, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val fruit = fruitList[position]
        holder.fruitImage.setImageResource(fruit.imageId)
        holder.fruitName.text = fruit.name
    }

    override fun getItemCount() = fruitList.size
}

总体上重写onCreateViewHolder,onBindViewHolder, getItemCount 方法就行,省区了自己管理view的流程 onCreateViewHolder()方法是用于创建ViewHolder实例的,我们在这个方法中将fruit_item布局加载进来,然后创建一个ViewHolder实例,并把加载出来的布局传入构造 函数当中,最后将ViewHolder的实例返回 onBindViewHolder()方法用于对RecyclerView子项的数据进行赋值,会在每个子项被滚动到屏幕内的时候执行 getItemCount()方法就非常简单了,它用于告诉RecyclerView一共有多少子项,直接返回数据源的长度就可以了。

MainActivity中的onCreate方法

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)
        initFruits()
        val layoutManager = LinearLayoutManager(this)
        bind.recyclerView.layoutManager = layoutManager
        val adapter = FruitAdapter(fruitList)
        bind.recyclerView.adapter = adapter
    }

LayoutManager用于指定RecyclerView的布局方式,这里使用的LinearLayoutManager是线性布局的意思

实现横向滚动和瀑布流布局

ListView的扩展性并不好,它只能实现纵向滚动的效果 在xml中将fruit_item的LinearLayout改成vertical方向

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="80dp"
    android:layout_height="wrap_content"
    >

    <ImageView
        android:id="@+id/fruitImage"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="10dp" />

    <TextView
        android:id="@+id/fruitName"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="left"
        android:layout_marginTop="10dp" />

</LinearLayout>

修改onCreate方法中的layoutManager的方向即可

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)
        initFruits()
        val layoutManager = LinearLayoutManager(this)
	// 此处设置为水平
        layoutManager.orientation = LinearLayoutManager.HORIZONTAL
        bind.recyclerView.layoutManager = layoutManager
        val adapter = FruitAdapter(fruitList)
        bind.recyclerView.adapter = adapter
    }

为什么ListView很难或者根本无法实现的效果在RecyclerView上这么轻松就实现了呢?这主要 得益于RecyclerView出色的设计。**ListView的布局排列是由自身去管理的,而RecyclerView 则将这个工作交给了LayoutManager。**LayoutManager制定了一套可扩展的布局排列接口, 子类只要按照接口的规范来实现,就能定制出各种不同排列方式的布局了。

除了LinearLayoutManager之外,RecyclerView还给我们提供了GridLayoutManager和 StaggeredGridLayoutManager这两种内置的布局排列方式。GridLayoutManager可以用于 实现网格布局,StaggeredGridLayoutManager可以用于实现瀑布流布局。

  • 使用StaggeredGridLayoutManager实现瀑布流布局
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    >

    <ImageView
        android:id="@+id/fruitImage"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="10dp" />

    <TextView
        android:id="@+id/fruitName"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="left"
        android:layout_marginTop="10dp" />

</LinearLayout>

Kotlin中只需要修改LayoutManager类型即可

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)
        initFruits()
//        val layoutManager = LinearLayoutManager(this)
        val layoutManager = StaggeredGridLayoutManager(3, StaggeredGridLayoutManager.VERTICAL)
        bind.recyclerView.layoutManager = layoutManager
        val adapter = FruitAdapter(fruitList)
        bind.recyclerView.adapter = adapter
    }

RecyclerView的点击事件

RecyclerView并没有提供类似于setOnItemClickListener()这样的注册监听器方法,而是需要我们自己给子项具体的View 去注册点击事件。这相比于ListView来说,实现起来要复杂一些

修改FruitAdapter中的代码

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.fruit_item, parent, false)
        val viewHolder = ViewHolder(view)

	// itemView 表示最外层布局
        viewHolder.itemView.setOnClickListener {
            val position = viewHolder.absoluteAdapterPosition
            val fruit = fruitList[position]
            Toast.makeText(parent.context, "you clicked view ${fruit.name}", Toast.LENGTH_LONG).show()
        }

        viewHolder.fruitImage.setOnClickListener {
            val position = viewHolder.absoluteAdapterPosition
            val fruit = fruitList[position]
            Toast.makeText(parent.context, "you clicked image ${fruit.name}", Toast.LENGTH_LONG).show()
        }

        return viewHolder
    }

编写界面的最佳实践

编写聊天界面

制作9-Patch图片

9-Patch图片:它是一种被特殊处理过的png图片,能够指定哪些区域可以被拉伸、哪些区域不可以

  • 设置activity_main.xml的LinearLayout背景图片
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="50dp"
    android:background="@drawable/message_left"
    tools:context=".MainActivity">
</LinearLayout>

注意此处背景图片资源为随书资源的9-Patch图片 在Android Studio中,我们可以将任何png类型的图片制作成9-Patch图片。首先对着 message_left.png图片右击Create 9-Patch file 使用鼠标在需要拉伸的部分拖动绘制褐色标记,可以按住Shift拖动擦除,具体请查阅资料

注意 最后记得要将原来的message_left.png图片删除,只保留制作好的message_left.9.png图片 即可,因为Android项目中不允许同一文件夹下有两张相同名称的图片(即使后缀名不同也不行)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#d8e0e8"
    >
    <!-- 用于显示聊天消息-->
    <androidx.recyclerview.widget.RecyclerView
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:id="@+id/recyclerView"
        android:layout_weight="1"
        />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

       <EditText
           android:id="@+id/inputText"
           android:layout_width="0dp"
           android:layout_height="wrap_content"
           android:layout_weight="1"
           android:hint="输入"
           android:maxLines="2"
           />

        <Button
            android:id="@+id/send"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="发送"
            />

    </LinearLayout>


</LinearLayout>

主界面中放置了一个RecyclerView用于显示聊天的消息内容,又放置了一个EditText用 于输入消息,还放置了一个Button用于发送消息

消息类

class Msg(val content: String, val type: Int) {
    companion object {
        // 表示收到的消息
        const val TYPE_RECEIVED = 0
        // 表示发送的消息
        const val TYPE_SENT = 1
    }
}

定义常量的关键字是 const 注意只有在单例类、companion object或顶层方法中才可以使用const关键字

新建RecyclerView的子布局(分别为左右消息图形显示建立布局xml文件)

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="start"
        android:background="@drawable/message_left">

        <TextView
            android:id="@+id/leftMsg"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_margin="10dp"
            android:textColor="#fff"/>

    </LinearLayout>


</FrameLayout>

Msg类以及适配器

/**
 * MsgAdapter继承RecyclerView 的Adapter
 * 泛型设置为RecyclerView.ViewHolder
 * 使用两个内部类作为Holder,继承RecyclerView.ViewHolder
  */
class MsgAdapter(val msgList: List<Msg>):
        RecyclerView.Adapter<RecyclerView.ViewHolder>() {
            inner class LeftViewHolder(view: View): RecyclerView.ViewHolder(view) {
                // 初始化leftMsg
        val leftMsg: TextView = view.findViewById(R.id.leftMsg)
            }
            inner class RightViewHolder(view: View): RecyclerView.ViewHolder(view) {
                // 初始化rightMsg
        val rightMsg: TextView = view.findViewById(R.id.rightMsg)
            }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = if (viewType == Msg.TYPE_RECEIVED) {
        // 根据不同类型来创建不同的View
        val view = LayoutInflater.from(parent.context).inflate(R.layout.msg_left_item, parent, false)
        LeftViewHolder(view)
    } else {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.msg_right_item, parent, false)
        RightViewHolder(view)
    }

    // 重写getItemViewType方法,支持Kotlin语法糖 obj.viewType
    override fun getItemViewType(position: Int): Int {
        val msg = msgList[position]
        return msg.type
    }

    // 用于设置View的实际内容
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val msg = msgList[position]
        when(holder) {
            is LeftViewHolder -> holder.leftMsg.text = msg.content
            is RightViewHolder -> holder.rightMsg.text = msg.content
            else -> throw IllegalArgumentException()
        }
    }

    // 返回msg的数量
    override fun getItemCount() = msgList.size
}

MainActivity

class MainActivity : AppCompatActivity() {

    private val msgList = ArrayList<Msg>()

    private var adapter: MsgAdapter? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)
        initMsg()
        val layoutManager = LinearLayoutManager(this)
        bind.recyclerView.layoutManager = layoutManager
        adapter = MsgAdapter(msgList)
        bind.recyclerView.adapter = adapter
        bind.send.setOnClickListener {
            val content = bind.inputText.text.toString()
            if (content.isNotEmpty()) {
                val msg = Msg(content, Msg.TYPE_SENT)
                msgList.add(msg)
                // 当有新消息时刷新RecyclerView中的显示
                adapter?.notifyItemInserted(msgList.size - 1)
                // 将RecyclerView定位到最后一行
                bind.recyclerView.scrollToPosition(msgList.size - 1)
                // 清空输入框
                bind.inputText.setText("")
            }
        }
    }

    private fun initMsg() {
        val msg1 = Msg("Hello guy.", Msg.TYPE_RECEIVED)
        msgList.add(msg1)
        val msg2 = Msg("Hello. Who is that?", Msg.TYPE_SENT)
        msgList.add(msg2)
        val msg3 = Msg("This is Tom. Nice talking to you. ", Msg.TYPE_RECEIVED)
        msgList.add(msg3)
    }
}

Kotlin 延迟初始化和密封类

对变量延迟初始化

使用 lateinit 关键字它可以告诉Kotlin编译器,我会在晚些时候对这个变量进行初始化, 这样就不用在一开始的时候将它赋值为null了。

class MainActivity : AppCompatActivity(), View.OnClickListener {
    private lateinit var adapter: MsgAdapter
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        adapter = MsgAdapter(msgList)
        ...
    }
    override fun onClick(v: View?) {
        ...
	// 原先此处的判空操作也不需要了
        adapter.notifyItemInserted(msgList.size - 1)
        ...
    }
}

对一个全局变量使用了lateinit关键字时,请一定要确保它在被任何地方调用之前 已经完成了初始化工作,否则Kotlin将无法保证程序的安全性

可以通过代码判断是否已经初始化

if (!::adapter.isInitialized) {
	// ...
}

::adapter.isInitialized可用于判断adapter变量是否已经初始化。 虽然语法看上去有点奇怪,但这是固定的写法。

使用密封类优化代码

实例代码

import java.lang.Exception

interface Result

class Success(val msg: String) : Result

class Failure(val error: Exception) : Result

fun getResultMsg (result: Result) = when(result) {
    is Success -> result.msg
    is Failure -> result.error
    else -> throw IllegalArgumentException()
}

问题: 我们不得不再编写一个else条 件,否则Kotlin编译器会认为这里缺少条件分支,代码将无法编译通过。但实际上Result的执 行结果只可能是Success或者Failure,这个else条件是永远走不到的,所以我们在这里直接 抛出了一个异常,只是为了满足Kotlin编译器的语法检查而已。 另外,编写else条件还有一个潜在的风险。如果我们现在新增了一个Unknown类并实现 Result接口,用于表示未知的执行结果,但是忘记在getResultMsg()方法中添加相应的条 件分支,编译器在这种情况下是不会提醒我们的,而是会在运行的时候进入else条件里面,从 而抛出异常并导致程序崩溃。

使用密封类解决该问题

import java.lang.Exception

sealed class Result

class Success(val msg: String) : Result()

class Failure(val error: Exception) : Result()

fun getResultMsg (result: Result) = when(result) {
    is Success -> result.msg
    is Failure -> result.error
}
注意,密封类是可以被继承的,Result后要加小括号
**当在when语句中传入一个密封类变量作为条件时,Kotlin编译器会自动检查该密封类有哪些子类,并强制要求你将每一个子类所对应** 的条件全部处理。这样就可以保证,即使没有编写else条件,也不可能会出现漏写条件分支的 情况。而如果我们现在新增一个Unknown类,并也让它继承自Result,此时getResultMsg()方法就一定会报错,必须增加一个Unknown的条件分支才能让代码编译通过。
注意 密封类及其所有子类只能定义在同一个文件的顶层位置,不能嵌套在其他类中,这是被密封类底层的实现机制所限制的
修改之前代码 新增MsgViewHolder.kt文件
import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

sealed class MsgViewHolder (view: View): RecyclerView.ViewHolder(view)

class LeftViewHolder(view: View): MsgViewHolder(view) {
    val leftMsg: TextView = view.findViewById(R.id.leftMsg)
}

class RightViewHolder(view: View): MsgViewHolder(view) {
    val rightMsg: TextView = view.findViewById(R.id.rightMsg)
}

修改MsgAdapter中的代码

class MsgAdapter(val msgList: List<Msg>) : RecyclerView.Adapter<MsgViewHolder>() {
    ...
    override fun onBindViewHolder(holder: MsgViewHolder, position: Int) {
        val msg = msgList[position]
        when (holder) {
            is LeftViewHolder -> holder.leftMsg.text = msg.content
            is RightViewHolder -> holder.rightMsg.text = msg.content
        }
    }
    ...
}

Fragment

Fragment是什么

Fragment是一种可以嵌入在Activity当中的UI片段,它能让程序更加合理和充分地利用大屏幕 和Activity非常像 的空间

Fragment的使用方式

新建fragment的view

此处实现左右fragment平分activity

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:orientation="vertical"
    android:background="#00ff00"
    android:layout_height="match_parent">

    <TextView
        android:layout_gravity="center_horizontal"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:text="This is right fragment"
        />

</LinearLayout>

activity_main.xml内容

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <fragment
        android:id="@+id/leftFrag"
        android:layout_width="0dp"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_height="match_parent"
        android:layout_weight="1"/>

    <fragment
        android:id="@+id/rightFrag"
        android:layout_width="0dp"
        android:name="com.example.fragmenttest.RightFragment"
        android:layout_height="match_parent"
        android:layout_weight="1"/>

</LinearLayout>

新建类,继承子Androidx下的Fragment

系统内置的Fragment在Android9.0已经被弃用

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment

class LeftFragment: Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.left_fragment, container, false)
    }
}

class RightFragment: Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.right_fragment, container, false)
    }
}

动态添加Fragment

Fragment可以在程序运行时,动态地添加到Activity中 新建another_right_fragment.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:background="#ffff00"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:textSize="24sp"
        android:text="This is another right fragment"
        />

</LinearLayout>

kotlin class

class AnotherRightFragment: Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.another_right_fragment, container, false)
    }
}

修改activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <fragment
        android:id="@+id/leftFrag"
        android:layout_width="0dp"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_height="match_parent"
        android:layout_weight="1"/>

    <FrameLayout
        android:id="@+id/rightLayout"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        />


</LinearLayout>

修改MainActivity中的代码

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.fragment.app.Fragment
import com.example.fragmenttest.databinding.ActivityMainBinding
import com.example.fragmenttest.databinding.LeftFragmentBinding

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)

	// 此处注意,这样写,btn将无法注册监听事件
        // val b1 = LeftFragmentBinding.inflate(layoutInflater)
	// 获取btn并且设置监听事件应该在其父View设置,即使用viewBinding 绑定父View
	// 或者目前使用findViewById
	val btn = findViewById<Button>(R.id.btn)
        btn.setOnClickListener {
            replaceFragment(AnotherRightFragment())
        }
        replaceFragment(RightFragment())
    }

    private fun replaceFragment(fragment: Fragment) {
        val fragmentManager = supportFragmentManager
        val transaction = fragmentManager.beginTransaction()
        transaction.replace(R.id.rightLayout, fragment)
        transaction.commit()
    }
}

此时Fragment并没有加入返回栈中,直接返回会退出程序

动态添加Fragment的步骤

(1) 创建待添加Fragment的实例。 (2) 获取FragmentManager,在Activity中可以直接调用getSupportFragmentManager() 方法获取。 (3) 开启一个事务,通过调用beginTransaction()方法开启。 (4) 向容器内添加或替换Fragment,一般使用replace()方法实现,需要传入容器的id和待添 加的Fragment实例。 (5) 提交事务,调用commit()方法来完成。

在Fragment中实现返回栈

调用addToBackStack方法即可

private fun replaceFragment(fragment: Fragment) {
        val fragmentManager = supportFragmentManager
        val transaction = fragmentManager.beginTransaction()
        transaction.replace(R.id.rightLayout, fragment)
        transaction.addToBackStack(null)
        transaction.commit()
    }

addToBackStack 接受一个名字描述返回栈的状态

Fragment和Activity之间的交互

为了方便Fragment和Activity之间进行交互,FragmentManager提供了一个类似于 findViewById()的方法,专门用于从布局文件中获取Fragment的实例

val fragment = supportFragmentManager.findFragmentById(R.id.leftFrag) as LeftFragment

调用FragmentManager的findFragmentById()方法,可以在Activity中得到相应 Fragment的实例,然后就能轻松地调用Fragment里的方法了。 同样 在每个Fragment中都可以通过调用getActivity()方法来得到和当前Fragment相关联的Activity实例

if (activity != null) {
	val mainActivity = activity as MainActivity
}

另外当Fragment中需要使用Context对象时,也可以使用getActivity()方法,因为获取到的Activity本身就是一个Context对象。

不同的Fragment之间通信,可以让其中一个Fragment获取Activity,在通过Activity获取其他的Fragment

Fragment的生命周期

  1. 运行状态 当一个Fragment所关联的Activity正处于运行状态时,该Fragment也处于运行状态。
  2. 暂停状态 当一个Activity进入暂停状态时(由于另一个未占满屏幕的Activity被添加到了栈顶),与 它相关联的Fragment就会进入暂停状态。
  3. 停止状态 当一个Activity进入停止状态时,与它相关联的Fragment就会进入停止状态,或者通过调 用FragmentTransaction的remove()、replace()方法将Fragment从Activity中移 除,但在事务提交之前调用了addToBackStack()方法,这时的Fragment也会进入停止 状态。总的来说,进入停止状态的Fragment对用户来说是完全不可见的,有可能会被系统 回收。
  4. 销毁状态 Fragment总是依附于Activity而存在,因此当Activity被销毁时,与它相关联的 Fragment就会进入销毁状态。或者通过调用FragmentTransaction的remove()、 replace()方法将Fragment从Activity中移除,但在事务提交之前并没有调用 addToBackStack()方法,这时的Fragment也会进入销毁状态。

Fragment的回调方法

同样地,Fragment类中也提供了一系列的回调方法,以覆盖它生命周期的每个环节。其中,Activity中有的回调方法, Fragment中基本上也有,不过Fragment还提供了一些附加的回调方法

  • onAttach():当Fragment和Activity建立关联时调用。
  • onCreateView():为Fragment创建视图(加载布局)时调用。
  • onActivityCreated():确保与Fragment相关联的Activity已经创建完毕时调用。
  • onDestroyView():当与Fragment关联的视图被移除时调用。
  • onDetach():当Fragment和Activity解除关联时调用。

Fragment完整生命周期

flowchart
s1([添加一个Fragment]) --> s2["onAttach()"]
s2 --> s3["onCreate()"]
s3 --> s4["onCreateView()"]
s4 --> s5["onActivityCreated()"]
s5 --> s6["onStart()"]
s6 --> s7["onResume()"]
s7 --> s8[Fragment已激活]
s8 -- 用户点击返回键或Fragment被移除/替换 ----> s9["onPause()"]
s8 -- 当Fragment被添加到返回栈,然后被移除/替换 ----> s9
s9 --> s10["onStop()"]
s9 --> s10
s10 --> s11["onDestroyView()"]
s10 --> s11
s11 --> s12["onDestroy()"]
s11 -- 从返回栈中返回上一个Fragment ----> s4
s12 --> s13["onDetach()"]
s13 --> s14([Fragment被销毁])

体验Fragment的生命周期

class RightFragment: Fragment() {

    companion object {
        const val TAG = "RightFragment"
    }

    override fun onAttach(context: Context) {
        super.onAttach(context)
        Log.d(TAG, "onAttach")
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d(TAG, "onCreate")
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        Log.d(TAG, "onCreateView")
        return inflater.inflate(R.layout.right_fragment, container, false)
    }

    // 此处该方法已经被弃用
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        Log.d(TAG, "onActivityCreated")
        super.onActivityCreated(savedInstanceState)
    }

    override fun onStart() {
        Log.d(TAG, "onStart")
        super.onStart()
    }

    override fun onResume() {
        Log.d(TAG, "onResume")
        super.onResume()
    }

    override fun onPause() {
        Log.d(TAG, "onPause")
        super.onPause()
    }

    override fun onStop() {
        Log.d(TAG, "onStop")
        super.onStop()
    }

    override fun onDestroyView() {
        Log.d(TAG, "onDestroyView")
        super.onDestroyView()
    }

    override fun onDestroy() {
        Log.d(TAG, "onDestroy")
        super.onDestroy()
    }

    override fun onDetach() {
        Log.d(TAG, "onDetach")
        super.onDetach()
    }
}

在Fragment中也可以通过 onSaveInstanceState() 方法来保存数据

动态加载布局的技巧

使用限定符

在res文件夹下的layout文件夹下创建layout-large文件夹,里面创建一个activity_main.xml文件, 该文件与layout文件夹下的文件同名,layout与layout-large对应了不同屏幕尺寸的设备的布局文件 此处两个文件内容为

layout文件夹下的activity_main.xml文件内容

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    tools:context=".MainActivity">

    <fragment
        android:id="@+id/leftFrag"
        android:layout_width="0dp"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_height="match_parent"
        android:layout_weight="1"/>


</LinearLayout>

layout-large文件夹下的activity_main.xml文件内容

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <fragment
        android:id="@+id/leftFrag"
        android:layout_width="0dp"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_height="match_parent"
        android:layout_weight="1"
        />


    <fragment
        android:id="@+id/rightFrag"
        android:layout_width="0dp"
        android:name="com.example.fragmenttest.RightFragment"
        android:layout_height="match_parent"
        android:layout_weight="3"
        />

</LinearLayout>

对应MainActivity代码依然如之前,只是注释掉了replaceFragment内容, 分别启动平板虚拟和手机虚拟机,程序会分别加载layout-large和layout下的activity_main.xml文件 由此实现了同一个程序,动态加载布局的效果

Android中常见的限定符
屏幕特征限定符描述
大小small提供给小屏幕设备的资源
normal提供给中等屏幕设备的资源
large提供给大屏幕设备的资源
xlarge提供给超大屏幕的设备
分辨率ldpi提供给低分辨率设备的资源 (120dpi以下)
mdpi提供给中等分辨率设备的资源 (120dpi ~ 160dpi)
hdpi提供给高分辨率设备的资源 (160dpi ~ 240dpi)
xhdpi提供给超高分辨率设备的资源 (240dpi ~ 320dpi)
xxhdpi提供给超超高分辨率设备的资源 (320dpi ~ 480dpi)
方向land提供给横屏设备的资源
port提供给竖屏设备的资源

使用最小宽度限定符

最小宽度限定符允许我们对屏幕的宽度指定一个最小值(以dp为单位),然后以这个最小值为 临界点,屏幕宽度大于这个值的设备就加载一个布局,屏幕宽度小于这个值的设备就加载另一 个布局。 在res目录下新建layout-sw600dp文件夹,然后在这个文件夹下新建activity_main.xml布 局

<!--activity_main.xml 文件-->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <fragment
        android:id="@+id/leftFrag"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
    <fragment
        android:id="@+id/rightFrag"
        android:name="com.example.fragmenttest.RightFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="3" />

</LinearLayout>

这就意味着,当程序运行在** 屏幕宽度 大于等于600 dp的设备上时,会加载layout- sw600dp/activity_main布局,当程序运行在 屏幕宽度 小于600 dp的设备**上时,则仍然加载 默认的layout/activity_main布局。

Fragment最佳实践: 一个简易版的新闻应用

news_content_frag.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:id="@+id/contentLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:visibility="invisible"
        >
        <TextView
            android:id="@+id/newsTitle"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:padding="10dp"
            android:textSize="20sp"
            />
        <!--    水平细线用于分割标题和内容    -->
        <View
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:background="#000"/>
       <TextView
           android:id="@+id/newsContent"
           android:layout_width="match_parent"
           android:layout_height="0dp"
           android:layout_weight="1"
           android:padding="15dp"
           android:textSize="18sp"
           />
    </LinearLayout>
    <!--    竖直细线用于分割列表和内容    -->
    <View
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:layout_alignParentLeft="true"
        android:background="#000"
        />

</RelativeLayout>

要将新闻内容的布局设置成不可见。因为在双页模式下,如果还没有选中新闻列 表中的任何一条新闻,是不应该显示新闻内容布局的。 其他代码请参考仓库

注意不同分辨率文件夹下的相同xml文件根布局id需要相同
### Kotlin:扩展函数和运算符重载 #### 扩展函数 扩展函数表示即使在不修改某个类的源码的情况下,仍然可以打开这个类,向该类添加新的函数。 ##### 扩展函数语法结构
fun ClassName.methodName(param1: Int, param2: Int): Int {
	return 0
}

eg: 给String添加扩展函数

fun String.letterCount(): Int {
    var count = 0
    for(char in this) {
        if (char.isLetter()) {
            count++
        }
    }
    return count
}

扩展函数也是可以定义在任何一个现有类当中的,并不一定非要创建新文件。 不过通常来说,最好将它定义成顶层方法,这样可以让扩展函数拥有全局的访问域。

fun String.letterCount(): Int {
    var count = 0
    for(char in this) {
        if (char.isLetter()) {
            count++
        }
    }
    return count
}

fun main() {
    println("awefawefweafe".letterCount())
}

函数运算符重载

重载运算符使用 operator 关键字 要重载对应的运算符,需要使用 operator 关键字加上对应操作的函数名 eg: 重载+,operator fun plus eg: 重载字符串 * ,使字符串能像Python一样复制 另外,必须说明的是,其实Kotlin的String类中已经提供了一个用于将字符串重复 n 遍的 repeat()函数,

operator fun String.times(n: Int) = repeat(n)

fun main() {
    println("ss" * 3)
}

广播机制

广播简介

广播类型

  • 标准广播(normal broadcasts) 是一种完全异步执行的广播,在广播发出之后,所有的 BroadcastReceiver几乎会在同一时刻收到这条广播消息,因此它们之间没有任何先后顺 序可言。这种广播的效率会比较高,但同时也意味着它是无法被截断的。
flowchart LR
s1([发出第一条广播]) --> s2([BroadcastReceiver1])
s1 --> s3([BroadcastReceiver2])
s1 --> s4([BroadcastReceiver3])
  • 有序广播(ordered broadcasts) 则是一种同步执行的广播,在广播发出之后,同一时刻 只会有一个BroadcastReceiver能够收到这条广播消息,当这个BroadcastReceiver中的 逻辑执行完毕后,广播才会继续传递。所以此时的BroadcastReceiver是有先后顺序的, 优先级高的BroadcastReceiver就可以先收到广播消息,并且前面的BroadcastReceiver 还可以截断正在传递的广播,这样后面的BroadcastReceiver就无法收到广播消息了。
flowchart LR
s1([发出第一条广播]) --> s2([BroadcastReceiver1])
s2 -- 可将广播截断 --> s3([BroadcastReceiver2])
s3 -- 可将广播截断 --> s4([BroadcastReceiver3])

接收系统广播

动态监听时间变化

BroadcastReceiver的方式一般有两种:在代码中注册和在**AndroidManifest.xml中注册。**其 中前者也被称为动态注册,后者也被称为静态注册。

那么如何创建一个BroadcastReceiver呢?其实只需新建一个类,让它继承自 BroadcastReceiver,并重写父类的onReceive()方法就行了。这样当有广播到来时, onReceive()方法就会得到执行,具体的逻辑就可以在这个方法中处理。

class MainActivity : AppCompatActivity() {

    private lateinit var timeChangeReceiver: TimeChangeReceiver

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val intentFilter = IntentFilter()
        intentFilter.addAction("android.intent.action.TIME_TICK")
        timeChangeReceiver = TimeChangeReceiver()
        registerReceiver(timeChangeReceiver, intentFilter)
    }

    override fun onDestroy() {
        super.onDestroy()
        unregisterReceiver(timeChangeReceiver)
    }

    inner class TimeChangeReceiver: BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            Toast.makeText(context, "Time has changed", Toast.LENGTH_SHORT).show()
        }
    }
}

因为当系统时间发生变化时,系统发出的正是一条值为android.intent.action.TIME_TICK的广播, 也就是说我们的BroadcastReceiver想要监听什么广播,就在这里添加相应的action。接下 来创建了一个TimeChangeReceiver的实例,然后调用registerReceiver()方法进行注 册,将TimeChangeReceiver的实例和IntentFilter的实例都传了进去,这样 TimeChangeReceiver就会收到所有值为android.intent.action.TIME_TICK的广播, 也就实现了监听系统时间变化的功能。

系统每隔一分钟发送一条 android.intent.action.TIME_TICK 的广播

静态注册实现开机启动

动态注册的BroadcastReceiver可以自由地控制注册与注销,在灵活性方面有很大的优势。但 是它存在着一个缺点,即必须在程序启动之后才能接收广播,因为注册的逻辑是写在 onCreate()方法中的。 静态注册可以在程序未启动的状态下也能接收广播

使用android.intent.action.BOOT_COMPLETED

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.widget.Toast

class BootCompleteReceiver : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        Toast.makeText(context, "Boot Complete", Toast.LENGTH_LONG).show()
    }
}

静态的BroadcastReceiver一定要在AndroidManifest.xml文件中注册才可以使用

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

    <!-- <application.... -->
    <!-- ... -->
<receiver
            android:name=".BootCompleteReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED"/>
            </intent-filter>
        </receiver>

另外,这里有非常重要的一点需要说明。Android 系统为了保护用户设备的安全和隐私,做了 严格的规定:**如果程序需要进行一些对用户来说比较敏感的操作,必须在 AndroidManifest.xml文件中进行权限声明,否则程序将会直接崩溃。**比如这里接收系统的开 机广播就是需要进行权限声明的,所以我们在上述代码中使用 <uses-permission> 标签声明 了android.permission.RECEIVE_BOOT_COMPLETED权限

需要注意的是,不要在onReceive()方法中添加过多的逻辑或者进行任何的耗时操作,因为 BroadcastReceiver中是不允许开启线程的,当onReceive()方法运行了较长时间而没有结 束时,程序就会出现错误。

发送自定义广播

发送标准广播

继承BroadCastReceiver并重写onReceive方法

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.widget.Toast

class MyBroadcastReceiver: BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        Toast.makeText(context, "received in MyBroadcastReceiver", Toast.LENGTH_SHORT).show()
    }
}

修改AndroidMainifest.xml

<receiver
            android:name=".MyBroadcastReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
                <action android:name="my_broadcast"/>
            </intent-filter>
        </receiver>

修改onCreate方法

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)
        bind.button.setOnClickListener {
            val intent = Intent("com.example.broadcasttest.MY_BROADCAST")
            intent.setPackage(packageName)
            sendBroadcast(intent)
        }
}

调用Intent的setPackage()方法,并传入当前应用程序的包名。packageName是getPackageName()的语法糖写法,用 于获取当前应用程序的包名。最后调用sendBroadcast()方法将广播发送出去,这样所有监 听com.example.broadcasttest.MY_BROADCAST这条广播的BroadcastReceiver就会收 到消息了。此时发出去的广播就是一条标准广播。

这里我还得对第2步调用的setPackage()方法进行更详细的说明。前面已经说过,在Android 8.0系统之后,静态注册的BroadcastReceiver是无法接收隐式广播(隐式广播指的 是那些没有具体指定发送给哪个应用程序的广播,大多数系统广播属于隐式广播)的,而默认情况下我们发出 的自定义广播恰恰都是隐式广播。因此这里一定要调用setPackage()方法,指定这条广播是 发送给哪个应用程序的,从而让它变成一条显式广播,否则静态注册的BroadcastReceiver将 无法接收到这条广播。

发送有序广播

新建AnotherBroadcastReceiver

class AnotherBroadcastReceiver: BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        Toast.makeText(context, "received in AnotherBroadcastReceiver", Toast.LENGTH_SHORT).show()
    }
}

在AndroidMainifest.xml文件中配置

<receiver
            android:name=".AnotherBroadcastReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
                <action android:name="com.example.broadcasttest.MY_BROADCAST"/>
            </intent-filter>
        </receiver>

kotlin代码

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)
        bind.button.setOnClickListener {
            val intent = Intent("com.example.broadcasttest.MY_BROADCAST")
            intent.setPackage(packageName)
//            sendBroadcast(intent)
            sendOrderedBroadcast(intent, null)
        }

之修改了发送广播的方法从 sendBroadcast 改为 sendOrderedBroadcast 即可

设置BroadcastReceiver顺序 在AndroidMainifest.xml文件中设置优先级

<receiver
            android:name=".AnotherBroadcastReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter android:priority="100">
                <action android:name="com.example.broadcasttest.MY_BROADCAST"/>
            </intent-filter>
        </receiver>

        <receiver
            android:name=".MyBroadcastReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
                <action android:name="com.example.broadcasttest.MY_BROADCAST"/>
            </intent-filter>
        </receiver>

可以看到,我们通过android:priority属性给BroadcastReceiver设置了优先级,优先级 比较高的BroadcastReceiver就可以先收到广播。这里将MyBroadcastReceiver的优先级设 成了100,以保证它一定会在AnotherBroadcastReceiver之前收到广播。

如果在onReceive()方法中调用了abortBroadcast()方法,就表示将这条广播截断,后面 的BroadcastReceiver将无法再接收到这条广播。

class AnotherBroadcastReceiver: BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        Toast.makeText(context, "received in AnotherBroadcastReceiver", Toast.LENGTH_SHORT).show()
        abortBroadcast()
    }
}

广播的最佳实现:实现强制下线功能

编写ActivityController控制所有Activity

import android.app.Activity

object ActivityController {
    private val activities = ArrayList<Activity>()

    fun addActivity(activity: Activity) {
        activities.add(activity)
    }

    fun removeActivity(activity: Activity) {
        activities.remove(activity)
    }

    fun finishAll() {
        for (activity in activities) {
            if (!activity.isFinishing) {
                activity.finish()
            }
        }
        activities.clear()
    }
}

编写BaseActivity

import android.os.Bundle
import android.os.PersistableBundle
import androidx.appcompat.app.AppCompatActivity

open class BaseActivity: AppCompatActivity(){
    override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
        super.onCreate(savedInstanceState, persistentState)
        ActivityController.addActivity(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        ActivityController.removeActivity(this)
    }
}

编写登录布局xml文件

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <LinearLayout
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="60dp">
        <TextView
            android:layout_width="90dp"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:textSize="18sp"
            android:text="Account:" />
        <EditText
            android:id="@+id/accountEdit"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_gravity="center_vertical" />
    </LinearLayout>
    <LinearLayout
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="60dp">
        <TextView
            android:layout_width="90dp"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:textSize="18sp"
            android:text="Password:" />
        <EditText
            android:id="@+id/passwordEdit"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_gravity="center_vertical"
            android:inputType="textPassword" />
    </LinearLayout>
    <Button
        android:id="@+id/login"
        android:layout_width="200dp"
        android:layout_height="60dp"
        android:layout_gravity="center_horizontal"
        android:text="Login" />
</LinearLayout>

MainActivity

class MainActivity : BaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)

        bind.forceOffline.setOnClickListener {
            val intent = Intent("com.examplebroadcastbestpractice.FORCE_OFFLINE")
            sendBroadcast(intent)
        }
    }
}
注意: 强制用户下线的逻辑并不是写在MainActivity里的,而是应该写 在接收这条广播的BroadcastReceiver里。这样强制下线的功能就不会依附于任何界面了,不 管是在程序的任何地方,只要发出这样一条广播,就可以完成强制下线的操作了。

在BaseActivity中动态注册一个BroadcastReceiver,则所有Activity都可以被广播到

修改BaaseActivity代码

open class BaseActivity: AppCompatActivity(){

   private lateinit var receiver: ForceOfflineReceiver

    override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
        super.onCreate(savedInstanceState, persistentState)
        ActivityController.addActivity(this)
    }

    override fun onResume() {
        super.onResume()
        val intentFilter = IntentFilter()
        intentFilter.addAction("com.example.broadcastbestpractice.FORCE_OFFLINE")
        receiver = ForceOfflineReceiver()
        registerReceiver(receiver, intentFilter)
    }

    override fun onDestroy() {
        super.onDestroy()
        ActivityController.removeActivity(this)
    }

    inner class ForceOfflineReceiver: BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent?) {
            AlertDialog.Builder(context).apply {
                setTitle("Warning")
                setMessage("You are forced to be offline.\n Please try to login again.")
                setCancelable(false)
                setPositiveButton("OK") { _, _ ->
                    // 结束所有Activity之后,程序还在后台
                   ActivityController.finishAll()
                    // 创建Intent
                    val i = Intent(context, LoginActivity::class.java)
                    // 重新启动登录Activity
                    context.startActivity(i)
                }
		show()
            }
        }

    }
}

这里重写了onResume()和onPause()这两个生命周期方法,然后分别在这两个方法里注册和取 消注册了ForceOfflineReceiver。 **始终需要保证只有处于栈顶的Activity才能接收到这条强制下线广播,非栈顶的Activity不应该也没必要接收这条广播,**所以写在onResume()和 onPause()方法里就可以很好地解决这个问题,当一个Activity失去栈顶位置时就会自动取消 BroadcastReceiver的注册。

记得修改AndroidMainifest.xml文件,将主Activity修改为的LoginActivity

Kotlin:高阶函数

定义高阶函数

如果一个函数接收另一个函数作为参数,或者返回值的类型是另一个函数,那么该函数就称为高阶函数 Kotlin中存在函数类型的概念(其实其他与语言中也有相同的概念比如go,rust等语言中) 函数类型示例

// String, Int为参数类型,Unit为返回值类型,Unit相当于Java中的void
(String, Int) -> Unit

eg

fun example(func: (String, Int) -> Unit) {
	func("hello", 123)
}

fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int) = operation(num1, num2)
fun main(){
    // 传入时前面加上两个冒号
    println( num1AndNum2(12, 20, ::plus) )
    println( num1AndNum2(30, 233, ::minus) )
}

fun plus(num1: Int, num2: Int): Int {
    return num1 + num2
}

fun minus(num1: Int, num2: Int): Int {
    return num1 - num2
}

fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int) = operation(num1, num2)

由此,num1AndNum1 最后一个参数可以使用Lambda表达式来替代

println( num1AndNum2(2342, 2323) {n1, n2 -> n1 + n2} )
println( num1AndNum2(9898, 2323) {n1, n2 -> n1 - n2} )

高阶函数定义完整语法规则示例

fun StringBuilder.build(block: StringBuilder.() -> Unit): StringBuilder {
    block()
    return this
}

在函数类型的前面加上ClassName. 就表示这个函数类型是定义在哪个类当中的

那么这里将函数类型定义到StringBuilder类当中有什么好处呢?好处就是当我们调用build 函数时传入的Lambda表达式将会自动拥有StringBuilder的上下文,同时这也是apply函数的实现方式

fun main(){
    val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
    val result = StringBuilder().build {
        append("Start eating fruits.\n")
        for (fruit in list) {
            append(fruit).append("\n")
        }
        append("Ate all fruits.")
    }
}

内联函数的作用

高阶函数的实现原理
fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int {
    val result = operation(num1, num2)
    return result
}
fun main() {
    val num1 = 100
    val num2 = 80
    val result = num1AndNum2(num1, num2) { n1, n2 ->
        n1 + n2
    }
}

Kotlin编译器将上述代码大致转换为如下Java代码

public static int num1AndNum2(int num1, int num2, Function operation) {
    int result = (int) operation.invoke(num1, num2);
    return result;
}
public static void main() {
    int num1 = 100;
    int num2 = 80;
    int result = num1AndNum2(num1, num2, new Function() {
        @Override
        public Integer invoke(Integer n1, Integer n2) {
            return n1 + n2;
        }
    });
}

这就是Kotlin高阶函数背后的实现原理。你会发现,原来我们一直使用的Lambda表达式在底层 被转换成了匿名类的实现方式。这就表明,我们每调用一次Lambda表达式,都会创建一个新 的匿名类实例,当然也会造成额外的内存和性能开销。

为了解决这个问题,Kotlin提供了内联函数的功能,它可以将使用Lambda表达式带来的运行时 开销完全消除

内联函数的用法非常简单,只需要在定义高阶函数时加上inline关键字的声明即可

inline fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int) = operation(num1, num2)

Kotlin编译器会将内联函数中的代码在编译的时候自动替换到调用它的地方,这样也就不存在运行时的开销了。

noinline和crossinline

一个高阶函数中如果接收了两个或者更多函数类型的参数,这时我们给函数加上了inline关键字,那么Kotlin编译器会自动将所有引用的 Lambda表达式全部进行内联。

如果只是想内联其中一个Lambda表达式则可以使用 noinline 关键字

inline fun inlineTest(block1: () -> Unit, noinline block2: () -> Unit) {}

此处加上 inline 关键字之后,本来block1和block2都会被内联,但是block2前面加上了 noinline 关键字,所以此处只有block1会被内联

内联的函数类型参数在编译的时候会被进行代码替换,因此它没有真正的参数属性。 非内联的函数类型参数 可以自由地传递给其他任何函数,因为它就是一个真实的参数, 而内联的函数类型参数 只允许传递给另外一个内联函数,这也是它最大的局限性。

内联函数所引用的Lambda表达式中是可以使用return关键字来进行函数返回的,而非内联函数只能进行局部返回。

fun printString(str: String, block: (String) -> Unit) {
    println("printString begin")
    block(str)
    println("printString end")
}
fun main() {
    println("main start")
    val str = ""
    printString(str) { s ->
        println("lambda start")
        if (s.isEmpty()) return@printString
        println(s)
        println("lambda end")
    }
    println("main end")
}

// 以下是运行结果
main start
printString begin
lambda start
printString end
main end

注意,Lambda表达式中是不允许直接使用 return 关键字的,这里使用了 return@printString 的写法,表示进行局部返回,并且不再执行Lambda表达式的剩余部分代码。

将printString()函数声明成一个内联函数,那么情况就不一样了

inline fun printString(str: String, block: (String) -> Unit) {
    println("printString begin")
    block(str)
    println("printString end")
}
fun main() {
    println("main start")
    val str = ""
    printString(str) { s ->
        println("lambda start")
        if (s.isEmpty()) return
        println(s)
        println("lambda end")
    }
    println("main end")
}

此处表达式内可以使用return 关键字,但是此处return代表的是返回外层的调用函数,即main函数,此时main函数结束 下面为运行结果

//main start
//printString begin
//lambda start
使用内联可能出现的错误

将高阶函数声明成内联函数是一种良好的编程习惯,事实上,绝大多数高阶函数是可以直接声明成内联函数的 但是少部分情况也会报错,下面就是一种情况

inline fun runRunnable(block: () -> Unit) {
    val runnable = Runnable {
        block()
    }
    runnable.run()
}

这个错误出现的原因解释起来可能会稍微有点复杂。首先,在runRunnable()函数中,我们创 建了一个Runnable对象,并在Runnable的Lambda表达式中调用了传入的函数类型参数。而 Lambda表达式在编译的时候会被转换成匿名类的实现方式,也就是说,上述代码实际上是在 匿名类中调用了传入的函数类型参数。 而内联函数所引用的Lambda表达式允许使用return关键字进行函数返回,但是由于我们是在 匿名类中调用的函数类型参数,此时是不可能进行外层调用函数返回的,最多只能对匿名类中 的函数调用进行返回,因此这里就提示了上述错误。

套用之前printString的例子,使用inline之后,在lambda内直接使用了return关键字,但是,此时调用函数被放置在另一个匿名类中,此时的return无法对于main函数返回,而顶多只能 对内部的匿名类外层调用进行返回

如果我们在高阶函数中创建了另外的Lambda或者匿名类的实现,并且在这些实现 中调用函数类型参数,此时再将高阶函数声明成内联函数,就一定会提示错误

使用 crossinline 关键字解决

inline fun runRunnable(crossinline block: () -> Unit) {
    val runnable = Runnable {
        block()
    }
    runnable.run()
}

因为内联函数的Lambda表达式中允许使用return关键字,和高阶函数的匿名类实现中不允许使用return关键字之间造成了冲突所以产生了错误, 而crossinline关键字就像一个契约,它用 于保证在内联函数的Lambda表达式中一定不会使用return关键字,这样冲突就不存在了,问 题也就巧妙地解决了

但是在调用runRunnable函数时的Lambda表达式中就不能使用return关键字进行函数返回了但是可以使用return@runRunnable的形式进行局部返回

Git(版本控制工具)

说明: 此处不是不会git,只是跟着书一起做一个完整的随书笔记

git 配置身份

git config --global user.name "" # 填入名称
git config --global user.email "" # 填入邮箱

git 创建代码仓库

# 进入项目根目录之后
git init

git 提交本地代码 提交代码的方法非常简单,只需要使用add和commit 命令就可以了。add用于把想要提交的代码添加进来,commit则是真正执行提交操作。

git add build.gradle

一次性把所有文件添加好

git add .

进行提交

git commit -m "First commit"

数据存储方案,持久化技术

持久化技术简介

数据持久化就是指将那些内存中的瞬时数据保存到存储设备中,保证即使在手机或计算机关机 的情况下,这些数据仍然不会丢失 持久化技术提供了一种机制,可以让数据在瞬时状态和持久状态之间进行转换。 Android系统中主要提供了3种方式用于简单地实现数据持久化功能:文件存储SharedPreferences存储以及数据库存储

文件存储

将数据存储到文件中

Context类中提供了一个openFileOutput()方法,可以用于将数据存储到指定的文件中。这 个方法接收两个参数:第一个参数是文件名,在文件创建的时候使用,注意这里指定的文件名 不可以包含路径,因为所有的文件都默认存储到 /data/data/<package name>/files/目录下;第二个参数是文件的操作模式,

模式名称含义
MODE_PRIVATE指定相同文件名的时候,
所写入的内容将会覆盖原文件中的内容
MODE_APPEND如果该文件已存在,就往文件里面追加内容,
不存在就创建新文件

代码示例

fun save(inputText: String) {
    try {
	// 这个方法位于ContextWrapper中
        val output = openFileOutput("data", Context.MODE_PRIVATE)
        val writer = BufferedWriter(OutputStreamWriter(output))
        writer.use {
            it.write(inputText)
        }
    } catch (e: IOException) {
        e.printStackTrace()
    }
}

此处use方法会保证在Lambda表达式中的代码全部执行完之后自动将外层的流关闭 这样就不需要我们再编写一个finally语句,手动去关闭流了

修改activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/editText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="type something here"
        />

</LinearLayout>

修改MainActivity

class MainActivity : AppCompatActivity() {
    private lateinit var bind: ActivityMainBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)
    }

    override fun onDestroy() {
        super.onDestroy()
        val inputText = bind.editText.text.toString()
        save(inputText)
    }

    private fun save(text: String) {
        val outputStream = openFileOutput("data", Context.MODE_PRIVATE)
        val writer = BufferedWriter(OutputStreamWriter(outputStream))
        try {
           writer.use {
               it.write(text)
           }
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}

从文件中读取数据

类似于将数据存储到文件中,Context类中还提供了一个 openFileInput() 方法,用于从文 件中读取数据。这个方法要比 openFileOutput() 简单一些,它只接收一个参数,即要读取的 文件名,然后系统会自动到 /data/data/<package name>/files/ 目录下加载这个文件,并返 回一个FileInputStream对象,得到这个对象之后,再通过流的方式就可以将数据读取出来 了。

读取文件

private fun read(fileName: String): String {
        val content = StringBuffer()
        try {
            val inputStream = openFileInput(fileName)
            val reader = BufferedReader(InputStreamReader(inputStream))
            reader.use {
                reader.forEachLine {
                    content.append(it)
                }
            }
        }catch (e: IOException) {
            e.printStackTrace()
        }
        return content.toString()
    }

SharePreferences存储

不同于文件的存储方式,SharedPreferences是使用键值对的方式来存储数据的。也就是说, 当保存一条数据的时候,需要给这条数据提供一个对应的键,这样在读取数据的时候就可以通 过这个键把相应的值取出来。

将数据存储到SharedPreferences中

使用SharedPreferences对象来存储

  1. 使用Context类中的getSharedPreferences()方法 该方法有两个参数,第一个指定文件名, 第二个指定操作模式 目前只有默认的 MODE_PRIVATE这一种模式可选,它和直接传入0的效果是相同的,表示只有当前的应用程 序才可以对这个SharedPreferences文件进行读写。其他几种操作模式均已被废弃,

SharedPreferences文件都是存放在 /data/data/<packagename>/shared_prefs/ 目录下的

  1. 使用Activity类中的getPreferences() 这个方法只接收一个参数,即操作模式,这个方法在使用时会自动将当前Activity的类名作为SharedPreferences的文件名

得到了SharedPreferences对象之后,就可以开始向SharedPreferences文件中存储数 据了,主要可以分为3步实现。

  1. 调用SharedPreferences对象的edit()方法获取一个 SharedPreferences.Editor对象。
  2. 向SharedPreferences.Editor对象中添加数据,比如添加一个布尔型数据就使用 putBoolean()方法,添加一个字符串则使用putString()方法,以此类推。
  3. 调用apply()方法将添加的数据提交,从而完成数据存储操作。

实践 修改activity_main.xml文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/saveButton"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Save Data"
        android:textAllCaps="false"
        />

</LinearLayout>

修改MainActivity文件

import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.sharedpreferencestest.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)

        bind.saveButton.setOnClickListener {
            val editor = getSharedPreferences("data", Context.MODE_PRIVATE).edit()
            editor.putString("name", "Tom")
            editor.putInt("age", 28)
            editor.putBoolean("married", false)
            editor.apply()
        }
    }
}

从SharedPreferences读取数据

使用对应类型的get方法即可这些get方法都接收两个参数: 第一个参数是键,传入存储数据时使用的键就可以得到相应的值了; 第二个参数是默认值,即表示当传入的键找不到对应的值时会以什么样的默认值进行返回。

修改activity_main.xml文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/saveButton"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Save Data"
        android:textAllCaps="false"
        />

    <Button
        android:id="@+id/restoreButton"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Restore Data"
        />

</LinearLayout>

修改MainActivity文件

bind.restoreButton.setOnClickListener {
            val prefs = getSharedPreferences("data", Context.MODE_PRIVATE)
            val name = prefs.getString("name", "")
            val age = prefs.getInt("age", 0)
            val married = prefs.getBoolean("married", false)

            Log.d("MainActivity", "name is $name")
            Log.d("MainActivity", "age is $age")
            Log.d("MainActivity", "married is $married")
        }

SQLite数据库存储

SQLite是一款轻量级的关系型数据库,它的运算速度非常快,占用资 源很少,通常只需要几百KB的内存就足够了,因而特别适合在移动设备上使用。SQLite不仅支 持标准的SQL语法,还遵循了数据库的ACID事务

创建数据库

Android 专门提供了SQLiteOpenHelper类, SQLiteOpenHelper是一个抽象类,这意味着如果我们想要使用它,就需要创 建一个自己的帮助类去继承它。SQLiteOpenHelper中有两个抽象方法:onCreate()onUpgrade()。我们必须在自己的帮助类里重写这两个方法,然后分别在这两个方法中实现创建和升级数据库的逻辑。 数据库文件存在/data/data/<package name>/databases/ 目录下

SQLiteOpenHelper中还有两个非常重要的实例方法:getReadableDatabase()和 getWritableDatabase()。这两个方法都可以创建或打开一个现有的数据库**(如果数据库已存在则直接打开,否则要创建一个新的数据库)**,并返回一个可对数据库进行读写操作的对 象。不同的是,当数据库不可写入的时候(如磁盘空间已满),getReadableDatabase()方 法返回的对象将以只读的方式打开数据库,而getWritableDatabase()方法则将出现异常。

创建自己的DatabaseHelper类

import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper

class MyDatabaseHelper(context: Context, name: String, version: Int) :
    SQLiteOpenHelper(context, name, null, version) {

    private val createBook = "create table Book(" +
            " id integer primary key autoincrement, " +
            "author text, " +
            "price real, " +
            "pages integer, " +
            "name text)"

    override fun onCreate(db: SQLiteDatabase?) {
        db?.execSQL(createBook)
    }

    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
    }
}

在activity_main.xml中添加按钮 修改MainActivity中的代码

val dbHelper = MyDatabaseHelper(this, "booksStore.db", 1)

        bind.createDatabase.setOnClickListener {
            dbHelper.writableDatabase
        }

升级数据库

不能直接在onCreate方法中直接添加新增语句,因为数据库文件已经存在,onCreate方法不会再次执行 因此需要使用onUpdate方法

import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.widget.Toast

class MyDatabaseHelper(private val context: Context, name: String, version: Int) :
    SQLiteOpenHelper(context, name, null, version) {

    private val createBook = "create table Book(" +
            " id integer primary key autoincrement, " +
            "author text, " +
            "price real, " +
            "pages integer, " +
            "name text)"

    private val createCategory = "create table Category(" +
            "id integer primary key autoincrement, " +
            "category_name text, " +
            "category_code integer)"

    override fun onCreate(db: SQLiteDatabase?) {
        db?.apply {
            execSQL(createBook)
            execSQL(createCategory)
        }
        Toast.makeText(context, "Created succeeded", Toast.LENGTH_SHORT).show()
    }

    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
        db?.apply {
            db.execSQL("drop table if exists Book")
            db.execSQL("drop table if exists Category")
        }
        onCreate(db)
    }
}

还记得SQLiteOpenHelper的构造 方法里接收的第四个参数吗?它表示当前数据库的版本号,之前我们传入的是1,现在只要传入一个比1大的数,就可以让onUpgrade()方法得到执行了

添加数据

使用ContentValues对象来组装数据

bind.addData.setOnClickListener {
            val db = dbHelper.writableDatabase
            // 组装数据
            val values1 = ContentValues().apply {
                put("name", "The Da Vinvi Code")
                put("author", "Dan Brown")
                put("pages", 454)
                put("price", 16.96)
            }
            // 插入第一条数据
            db.insert("Book", null, values1)
            val values2 = ContentValues().apply {
                put("name", "The Lost Symbol")
                put("author", "Dan Brown")
                put("pages", 510)
                put("price", 19.95)
            }
            db.insert("Book", null, values2)
        }

更新数据

在activity_main.xml文件中添加更新按钮 修改MainActivity代码

bind.updateData.setOnClickListener {
            val db = dbHelper.writableDatabase
            val values = ContentValues()
            values.put("price", 10.99)
            db.update("Book", values, "name = ?", arrayOf("The Da Vinvi Code"))
        }

删除数据

同样在activity_main.xml中添加删除按钮 修改MainActivity代码

bind.deleteData.setOnClickListener {
            val db = dbHelper.writableDatabase
            db.delete("Book", "pages > ?", arrayOf("500"))
        }

查询数据

使用query方法查询,query方法参数较多 虽然query()方法的参数非常多,但是不要对它产生畏惧,因为我们不必为每条查询语句都指 定所有的参数,多数情况下只需要传入少数几个参数就可以完成查询操作了。调用query()方 法后会返回一个Cursor对象,查询到的所有数据都将从这个对象中取出。

query方法参数详解

query()方法参数对应SQL部分描述
tablefrom table_name制定查询的表名
columnsselect column1, column2指定查询的列名
selectionwhere column = value指定的where约束条件
selectionArgs-为where中的占位符提供具体的值
groupBygroup by column指定需要group by的列
havinghaving column = value对group by后的结果进一步约束
orderByirder by column1, column2指定查询结果的排序方式

在activity_main.xml文件中添加查询按钮 修改MainActivity中的代码

bind.queryData.setOnClickListener {
            val db = dbHelper.readableDatabase
            val cursor = db.query("Book", null, null, null, null, null, null)
            if (cursor.moveToFirst()) {
                // 遍历cursor对象,取出打印数据
                do {
                    val name = cursor.getString(cursor.getColumnIndex("name"))
                    val author = cursor.getString(cursor.getColumnIndex("author"))
                    val pages = cursor.getString(cursor.getColumnIndex("pages"))
                    val price = cursor.getString(cursor.getColumnIndex("price"))
                    Log.d("MainActivity", "book name is $name")
                    Log.d("MainActivity", "book author is $author")
                    Log.d("MainActivity", "book pages is $pages")
                    Log.d("MainActivity", "book price is $price")
                }while (cursor.moveToNext())
            }
            cursor.close()
        }

注意此处如果onCreate方法上没有加上 @SuppressLint("Range") 注解会报错,因为getColumnIndex方法返回可能为-1

使用SQL操作数据

添加数据

db?.execSQL("insert into Book(name, author, pages, price) values (?, ?, ?, ?)", arrayOf("The Da Vinvi", "Dan Brown", "454", "16.96"))
        db?.execSQL("insert into Book(name, author, pages, price) values (?, ?, ?, ?)", arrayOf("The Lost Symbol", "Dan Brown", "510", "19.95"))

更新数据

db?.execSQL("update Book set price = ? where pages > ?", arrayOf("10.99", "The Da Vinvi Code"))

删除数据

db?.execSQL("delete from Book where pages > ?", arrayOf("500"))

查询数据

db?.execSQL("select * from Book", null)

SQLite最佳实践

使用事务

在activity_main.xml文件中添加按钮 修改MainActivity文件

bind.replaceData.setOnClickListener {
            val db = dbHelper.writableDatabase
            db.beginTransaction()
            try {
                db.delete("Book", null, null)
                if (true) {
                    // 手动抛出异常让食物失败
                    throw NullPointerException()
                }
                val values = ContentValues().apply {
                    put("name", "Game of Thrones")
                    put("author", "George Martin")
                    put("pages", 720)
                    put("price", 20.85)
                }
                db.insert("Book", null, values)
                db.setTransactionSuccessful() // 事务已经执行成功
            } catch (e: Exception) {
                e.printStackTrace()
            } finally {
                db.endTransaction()
            }

        }

上述代码就是Android中事务的标准用法,首先调用SQLiteDatabase的 beginTransaction()方法开启一个事务,然后在一个异常捕获的代码块中执行具体的数据库 操作,当所有的操作都完成之后,调用setTransactionSuccessful()表示事务已经执行成 功了,最后在finally代码块中调用endTransaction()结束事务

升级数据库的最佳写法

每一个数据库版本都会对应一个版 本号,当指定的数据库版本号大于当前数据库版本号的时候,就会进入onUpgrade()方法中执行更新操作。

第一版程序

class MyDatabaseHelper(val context: Context, name: String, version: Int):
    SQLiteOpenHelper(context, name, null, version) {
    private val createBook = "create table Book (" +
            " id integer primary key autoincrement," +
            "author text," +
            "price real," +
            "pages integer," +
            "name text)"
    override fun onCreate(db: SQLiteDatabase) {
        db.execSQL(createBook)
    }
    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
    }
}

几星期之后又有了新需求,这次需要向数据库中再添加一张Category表。于是,修改 MyDatabaseHelper中的代码

class MyDatabaseHelper(val context: Context, name: String, version: Int):
    SQLiteOpenHelper(context, name, null, version) {
    private val createBook = "create table Book (" +
            " id integer primary key autoincrement," +
            "author text," +
            "price real," +
            "pages integer," +
            "name text)"
    private val createCategory = "create table Category (" +
            "id integer primary key autoincrement," +
            "category_name text," +
            "category_code integer)"
    override fun onCreate(db: SQLiteDatabase) {
        db.execSQL(createBook)
        db.execSQL(createCategory)
    }
    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        if (oldVersion <= 1) {
            db.execSQL(createCategory)
        }
    }
}

在onUpgrade()方法中添 加了一个if判断,如果用户数据库的旧版本号小于等于1,就只会创建一张Category表

新的需求又来了,这次要给Book表和Category表之间建立关联,需要在Book 表中添加一个category_id字段

class MyDatabaseHelper(val context: Context, name: String, version: Int):
    SQLiteOpenHelper(context, name, null, version) {
    private val createBook = "create table Book (" +
            " id integer primary key autoincrement," +
            "author text," +
            "price real," +
            "pages integer," +
            "name text," +
            "category_id integer)"
    private val createCategory = "create table Category (" +
            "id integer primary key autoincrement," +
            "category_name text," +
            "category_code integer)"
    override fun onCreate(db: SQLiteDatabase) {
        db.execSQL(createBook)
        db.execSQL(createCategory)
    }
    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        if (oldVersion <= 1) {
            db.execSQL(createCategory)
        }
        if (oldVersion <= 2) {
            db.execSQL("alter table Book add column category_id integer")
        }
    }
}

这里请注意一个非常重要的细节:每当升级一个数据库版本的时候,onUpgrade()方法里都一定要写一个相应的if判断语句 这是为了保证App在跨版本升级的时候,每一次的数据库修改都能被全部执行。

Kotlin 高阶函数应用

简化SharedPreferences的用法

SharedPreferences原来的用法。向SharedPreferences中存储数据的过程大致可以分为 以下3步:

  1. 调用SharedPreferences的edit()方法获取SharedPreferences.Editor对象;
  2. 向SharedPreferences.Editor对象中添加数据;
  3. 调用apply()方法将添加的数据提交,完成数据存储操作。 Java编程思想版代码
val editor = getSharedPreferences("data", Context.MODE_PRIVATE).edit()
editor.putString("name", "Tom")
editor.putInt("age", 28)
editor.putBoolean("married", false)
editor.apply()

新建SharedPreferences kt文件

import android.content.SharedPreferences

fun SharedPreferences.open(block: SharedPreferences.Editor.() -> Unit) {
    val editor = edit()
    editor.block()
    editor.apply()
}

由于open函数内拥有SharedPreferences的上下文,因此这里可以直接调用edit()方法来 获取SharedPreferences.Editor对象。另外open函数接收的是一个 SharedPreferences.Editor的函数类型参数,因此这里需要调用editor.block()对函数 类型参数进行调用,

注意此处block的类型表示的是当前SharedPreferences对象下的Editor对象,即只有当前SharedPreferences对象下的Editor对象才能调用block这个方法

之后在项目中可以如下使用

getSharedPreferences("data", Context.MODE_PRIVATE).open {
    putString("name", "Tom")
    putInt("age", 28)
    putBoolean("married", false)
}

当然,最后不得不提的是,其实Google提供的KTX扩展库中已经包含了上述 SharedPreferences的简化用法,这个扩展库会在Android Studio创建项目的时候自动引入 build.gradle的dependencies中

implementation 'androidx.core:core-ktx:1.8.0'
getSharedPreferences("data", Context.MODE_PRIVATE).edit {
            putString("name", "Tom")
            putInt("age", 28)
            putBoolean("married", false)
        }

简化ContentValues的用法

最基本用法

val values = ContentValues()
        values.put("name", "Game of Thrones")
        values.put("author", "George Martin")
        values.put("pages", 720)
        values.put("price", 20.85)
        db.insert("Book", null, values)

简单使用apply进行简化

val values1 = ContentValues().apply {
                put("name", "Game of Thrones")
                put("author", "George Martin")
                put("pages", 720)
                put("price", 20.85)
            }

mapOf函数允许我们使用"Apple" to 1这样的语法结构快速创建一 个键值对。在Kotlin中使用A to B这样的语法结构会创建一个Pair对象 新增ContentValues.kt文件,加入如下代码

import android.content.ContentValues

fun cvOf(vararg pair: Pair<String, Any?>): ContentValues {
    
}

首先,cvOf()方法接收了一个Pair参数,也就是使用A to B语法结构创建出来的参数类型 其次 vararg关键字对应的就是Java中的可变参数 列表,我们允许向这个方法传入0个、1个、2个甚至任意多个Pair类型的参数,这些参数都会被赋值到使用vararg声明的这一个变量上面, 然后使用for-in循环可以将传入的所有参数遍历出来。 ContentValues的所有键都是字符串类型的, 这里可以直接将Pair键的泛型指定成String。但ContentValues的值却可以有多种类型(字符串型、整型、浮点型,甚至是null),所以我们需要将Pair值的泛型指定成Any?。 Any是Kotlin中所有类的共同基类,相当于Java中的Object,而Any?则表示允许传入空值。

实现逻辑 核心思路就是先创建一个ContentValues对象,然后遍历pairs参数列表,取出其中的数据并填入ContentValues中,最终将 ContentValues对象返回即可

import android.content.ContentValues

fun cvOf(vararg pairs: Pair<String, Any?>): ContentValues {
    val cv = ContentValues()
    for (pair in pairs) {
        val key = pair.first
        when (val value = pair.second) {
            is Int -> cv.put(key, value)
            is Long -> cv.put(key, value)
            is Short -> cv.put(key, value)
            is Float -> cv.put(key, value)
            is Double -> cv.put(key, value)
            is Boolean -> cv.put(key, value)
            is String -> cv.put(key, value)
            is Byte -> cv.put(key, value)
            is ByteArray -> cv.put(key, value)
            null -> cv.putNull(key)
        }
    }
    return cv
}

之后向数据库添加数据可以写为

val values = cvOf("name" to "Game of Thrones", "author" to "George Martin",
    "pages" to 720, "price" to 20.85)
db.insert("Book", null, values)

还可以使用apply进一步简化代码,KTX库中也提供了一个具有同样功能的contentValuesOf()方法

val values = contentValuesOf("name" to "Game of Thrones", "author" to "George Martin",
    "pages" to 720, "price" to 20.85)
db.insert("Book", null, values)

跨程序共享数据,ContentProvider

ContentProvider简介

ContentProvider主要用于在不同的应用程序之间实现数据共享的功能,它提供了一套完整的 机制,允许一个程序访问另一个程序中的数据,同时还能保证被访问数据的安全性。 目前,使 用ContentProvider是Android实现跨程序共享数据的标准方式。

运行时权限

Android6.0引入

Android运行时机制

Android现在将常用的权限大致归成了两类,一类是普通权限,一类是危险权限。 普通权限指的是 那些不会直接威胁到用户的安全和隐私的权限,对于这部分权限申请,系统会自动帮我们进行 授权,不需要用户手动操作,比如在BroadcastTest项目中申请的权限就是普通权限。

危险权限指的是 那些可能会触及用户隐私或者对设备安全性造成影响的权限,如获取设备联系人信 息、定位设备的地理位置等,对于这部分权限申请,必须由用户手动授权才可以,否则程序就 无法使用相应的功能。

到Android10 系统为止所有的危险权限(这张表中的权限都需要运行时处理)

权限组名权限名
CALENDARREAD_CALENDAR
WRITE_CALENDAR
CALL_LOGREAD_CALL_LOG
WRITE_CALL_LOG
PROCESS_OUTGOING_CALLS
CAMERACAMERA
CONTACTSREAD_CONTACTS
WRITE_CONTACTS
GET_ACCOUNTS
LOCATIONACCESS_FINE_LOCATION
ACCESS_COARSE_LOCATION
ACCESS_BACKGROUND_LOCATION
MICROPHONERECORD_AUDIO
PHONEREAD_PHONE_STATE
READ_PHONE_NUMBERS
CALL_PHONE
ANSWER_PHONE_CALLS
ADD_VOICEMAIL
USE_SIP
ACCEPT_HANDOVER
SENSORSBODY_SENSORS
ACTIVITY_RECOGNITIONACTIVITY_RECOGNITION
SMSSEND_SMS
RECEIVE_SMS
READ_SMS
RECEIVE_WAP_PUSH
RECEIVE_MMS
STORAGEREAD_EXTERNAL_STORAGE
WRITE_EXTERNAL_STORAGE
ACCESS_MEDIA_LOCATION

每当要使用一个权限时,可以先到这张表中查一下,如果是这张表中的权限,就需要进行运行时权限 处理,否则,只需要在AndroidManifest.xml文件中添加一下权限声明就可以了。

在程序运行时申请权限

以申请CALL_PHONE这个权限为例 创建RuntimePermissionTest项目 修改activity_main.xml文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/makeCall"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Make Call"
        android:textAllCaps="false"
        />

</LinearLayout>

修改MainActivity中的代码

import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.runtimepermissiontest.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)

        bind.makeCall.setOnClickListener {
            try {
                val intent = Intent(Intent.ACTION_CALL)
                intent.data = Uri.parse("tel:10086")
                startActivity(intent)
            }catch (e: SecurityException) {
                e.printStackTrace()
            }
        }
    }
}

Intent.ACTION_DIAL,表示打开拨号界面,这个是不需要声明权限的,而 Intent.ACTION_CALL则表示直接拨打电话,因此必须声明权限。 修改AndroidMainifest.xml文件

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.runtimepermissiontest">
<uses-permission android:name="android.permission.CALL_PHONE" />
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
    ...
    </application>
</manifest>

这样我们就将拨打电话的功能成功实现了,并且在低于Android 6.0系统的手机上都是可以正常 运行的。但是,如果我们在Android 6.0或者更高版本系统的手机上运行,点击“Make Call”按 钮就没有任何效果了,并且会触发SecurityExecption异常

这是由于权限被禁止所导致的,因为Android 6.0及以上系统在使用危险权限时必须进行运行时权限处理

修复如上问题

import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.runtimepermissiontest.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val bind = ActivityMainBinding.inflate(layoutInflater)
        setContentView(bind.root)

        bind.makeCall.setOnClickListener {
            if (ContextCompat.checkSelfPermission(
                    this,
                    Manifest.permission.CALL_PHONE
                ) != PackageManager.PERMISSION_GRANTED
            ) {
                ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CALL_PHONE), 1)
            } else {
                call()
            }
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when (requestCode) {
            1 -> {
                if (grantResults.isNotEmpty() &&
                    grantResults[0] == PackageManager.PERMISSION_GRANTED
                ) {
                    call()
                } else {
                    Toast.makeText(this, "You denied the permission", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

    private fun call() {
        try {
            val intent = Intent(Intent.ACTION_CALL)
            intent.data = Uri.parse("tel:10086")
            startActivity(intent)
        } catch (e: SecurityException) {
            e.printStackTrace()
        }
    }
}

上面的代码覆盖了运行时权限的完整流程

访问其他程序中的数据

ContentProvider的用法一般有两种

  1. 使用现有的ContentProvider读取和操作相应程序中的数据
  2. 另一种是创建自己的ContentProvider,给程序的数据提供外部访问接口。

如果一个应用程序通过ContentProvider对其数据提供了外部访问接口,那么任何其他的应用 程序都可以对这部分数据进行访问。Android系统中自带的通讯录、短信、媒体库等程序都提供 了类似的访问接口,这就使得第三方应用程序可以充分地利用这部分数据实现更好的功能。

ContentResolver的基本用法

对于每一个应用程序来说,如果想要访问ContentProvider中共享的数据,就一定要借助 ContentResolver类,可以通过Context中的getContentResolver()方法获取该类的实 例。

ContentResolver中提供了一系列的方法用于对数据进行增删改查操作,其中insert() 方法用于添加数据,update()方法用于更新数据,delete()方法用于删除数据,query()方 法用于查询数据。

ContentResolver中的增删改查方法都是不接收表名参数的,而是使用一个Uri参数代替,这个参数被称为内容URI 内容Uri主要由authoritypath 两部分组成

一般为了避免冲突,会采用应用包名的方式进行命名。比如某个应用的包名是com.example.app,那么该应用对应的authority就可以命名为com.example.app.provider

path则是用于对同一应用程序中不同的表做区分的,通常会添加到authority的后面。 还需要在字符串的头部加上协议声明

内容Uri的标准格式如下

content://com.example.app.provider/table1
content://com.example.app.provider/table2

得到Uri字符串之后使用Uri.parse方法将字符串解析为Uri对象才能使用

val uri = Uri.parse("content:com.example.app.provider/table1")

由此可以使用Uri对象查询table1表中的数据

val cursor = contentResolver.query(
	uri,
	projection,
	selection,
	selectionArgs,
	sortOrder)

query()方法参数说明

query()方法参数对应SQL部分描述
urifrom table_name指定查询某个应用程序下的某一张表
projectionselect column1, column2指定查询的列名
selectionwhere column = value指定where的约束条件
selectionArgs-为where中的占位符提供具体的值
sortOrderorder by column1, column2指定查询结果的排序方式

查询完成后返回的仍然是一个Cursor对象,这时我们就可以将数据从Cursor对象中逐个读取 出来了。读取的思路仍然是通过移动游标的位置遍历Cursor的所有行,然后取出每一行中相应列的数据

while(cursor.moveToNext()){
	val column1 = cursor.getString(cursor.getColumnIndex("column1"))
	val column2 = cursor.getInt(cursor.getColumnIndex("column2"))
}
cursor.close()
  1. 向表中添加数据
val values = contentValueOf("column1" to "text", "column2" to 1)
contentResolver.insert(uri, values)
  1. 更新数据 如果我们想要更新这条新添加的数据,把column1的值清空,可以借助ContentResolver的update()方法实现
val values = contentVaulesOf("column1" to "")
contentResolver.update(uri, values, "column1 = ? and column2 = ?", arrayOf("text, 1"))
  1. 删除数据 使用delete方法

contentResolver.delete(uri, "column2 = ?", arrayOf("1"))

读取系统联系人

在虚拟机内创建两个联系人, 创建ContactsText项目 修改activity_main.xml文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    >

    <ListView
        android:id="@+id/contactsView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        />

</LinearLayout>

修改MainActivity代码

import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.provider.ContactsContract
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.contactstest.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    private lateinit var bind: ActivityMainBinding
    private val contactList = ArrayList<String>()
    private lateinit var adapter: ArrayAdapter<String>
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)
        adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, contactList)
        bind.contactsView.adapter = adapter
        if (ContextCompat.checkSelfPermission(
                this,
                Manifest.permission.READ_CONTACTS
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_CONTACTS), 1)
        } else {
            readContacts()
        }
    }

    @SuppressLint("Range")
    private fun readContacts() {
        // 查询联系人数据
        contentResolver.query(
            ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
            null,
            null,
            null,
            null
        )
            ?.apply {
                while (moveToNext()) {
                    // 获取联系人姓名
                    val displayName = getString(getColumnIndex(
                        ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME
                    ))
                    // 获取联系人号码
                    val number = getString(getColumnIndex(
                        ContactsContract.CommonDataKinds.Phone.NUMBER
                    ))
                    contactList.add("$displayName\n$number")
                }
                adapter.notifyDataSetChanged()
                close()
            }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when (requestCode) {
            1 -> {
                if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    readContacts()
                } else {
                    Toast.makeText(this, "You denied the permission", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
}

在传入的Uri上ContactsContract.CommonDataKinds.Phone类已经帮我们做好了封装 读取系统联系人的权限千万不能忘记声明。修改AndroidManifest.xml中的代码

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.contactstest">
	<uses-permission android:name="android.permission.READ_CONTACTS" />
...
</manifest>

创建自己的ContentProvider

创建ContentProvider的步骤

可以通过新建一个类去继承ContentProvider的方式来实现, ContentProvider类中有6个抽象方法,我们在使用子类继承它的时候,需要将这6个方法全部重写


import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri

class MyProvider: ContentProvider() {
    override fun onCreate(): Boolean {
        TODO("Not yet implemented")
    }

    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor? {
        TODO("Not yet implemented")
    }

    override fun getType(uri: Uri): String? {
        TODO("Not yet implemented")
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        TODO("Not yet implemented")
    }

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        TODO("Not yet implemented")
    }

    override fun update(
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<out String>?
    ): Int {
        TODO("Not yet implemented")
    }
}
  1. onCreate()。初始化ContentProvider的时候调用。通常会在这里完成对数据库的创建和 升级等操作,返回true表示ContentProvider初始化成功,返回false则表示失败。
  2. query()。从ContentProvider中查询数据。uri参数用于确定查询哪张表,projection 参数用于确定查询哪些列,selection和selectionArgs参数用于约束查询哪些行, sortOrder参数用于对结果进行排序,查询的结果存放在Cursor对象中返回。
  3. insert()。向ContentProvider中添加一条数据。uri参数用于确定要添加到的表,待添 加的数据保存在values参数中。添加完成后,返回一个用于表示这条新记录的URI。
  4. update()。更新ContentProvider中已有的数据。uri参数用于确定更新哪一张表中的数 据,新数据保存在values参数中,selection和selectionArgs参数用于约束更新哪些行, 受影响的行数将作为返回值返回。
  5. delete()。从ContentProvider中删除数据。uri参数用于确定删除哪一张表中的数据, selection和selectionArgs参数用于约束删除哪些行,被删除的行数将作为返回值返回。
  6. getType()。根据传入的内容URI返回相应的MIME类型。

现在我们需要对传入的uri参数进行解析,从中分析出调用方期望访问的表和数据。

标准URI的写法是

content://com.example.app.provider/table1

这就表示调用方期望访问的是com.example.app这个应用的table1表中的数据

除此之外,我们还可以在这个内容URI的后面加上一个id

content://com.example.app.provider/table1/1

这就表示调用方期望访问的是com.example.app这个应用的table1表中id为1的数据。

内容URI的格式主要就只有以上两种,以路径结尾表示期望访问该表中所有的数据以id结尾表示期望访问该表中拥有相应id的数据。

还可以使用通配符分别匹配两种格式内容的URI

  • * 表示匹配任意长度的任意字符
  • # 表示匹配任意长度的数字

一个能够匹配任意表的内容URI格式就可以写成

content://com.example.app.provider/table1/*

一个能够匹配table1表中任意一行数据的内容URI格式就可以写成

content://com.example.app.provider/table1/#

再借助UriMatcher这个类就可以轻松地实现匹配内容URI的功能 UriMatcher中 提供了一个addURI()方法,这个方法接收3个参数,可以分别把authority、path和一个自定义代码传进去。 当调用UriMatcher的match()方法时,就可以将一个Uri对象传入,返回值是某个能够匹配这个Uri对象所对应的自定义代码,利用这个代码, 我们就可以判断出调用方期望访问的是哪张表中的数据了

直接看MyProvider代码

class MyProvider: ContentProvider() {

    private val tableDir = 0
    private val tableItem = 1
    private val table2Dir = 2
    private val table2Item = 3

    private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)

    init {
        uriMatcher.addURI("com.example.app.provider", "table1", tableDir)
        uriMatcher.addURI("com.example.app.provider", "table1/#", tableItem)
        uriMatcher.addURI("com.example.app.provider", "table2", table2Dir)
        uriMatcher.addURI("com.example.app.provider", "table2/#", table2Item)
    }


    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor? {
        when (uriMatcher.match(uri)) {
            tableDir -> {
                // 查询table1表中的所有数据
            }
            table2Dir -> {
                // 查询table2表中的所有数据
            }
            tableItem -> {
                // 查询table2表中的单条数据
            }
            table2Item -> {
                // 查询table2表中的单条数据
            }
        }
	...
    }
    ...
}

上述代码只是以query()方法为例做了个示范,其实insert()、update()、delete()这几 个方法的实现是差不多的,它们都会携带uri这个参数,然后同样利用UriMatcher的 match()方法判断出调用方期望访问的是哪张表,再对该表中的数据进行相应的操作就可以了。

除此之外,还有一个方法你可能会比较陌生,即getType()方法。它是所有的 ContentProvider都必须提供的一个方法,用于获取Uri对象所对应的MIME类型。

一个内容URI对应的MIME字符串主要由3部分组成,Android对这3部分做了如下格式规定

  • 必须以vnd开头
  • 如果内容URI以路径结尾,则后接android.cursor.dir/;如果内容URI以id结尾,则后接android.cursor.item/。
  • 最后接上vnd.<authority>.<path>

所以,对于content://com.example.app.provider/table1这个内容URI,它所对应的MIME类型就可以写成:

vnd.android.cursor.dir/vnd.com.example.app.provider.table1

对于content://com.example.app.provider/table1/1这个内容URI,它所对应的MIME类型就可以写成:

vnd.android.cursor.item/vnd.com.example.app.provider.table1

实现getType()中的逻辑

override fun getType(uri: Uri) = when (uriMatcher.match(uri)) {
        tableDir -> "vnd.android.cursor.dir/vnd.com.example.app.provider.table1"
        tableItem -> "vnd.android.cursor.item/vnd.com.example.app.provider.table1"
        table2Dir -> "vnd.android.cursor.dir/vnd.com.example.app.provider.table2"
        table2Item -> "vnd.android.cursor.item/vnd.com.example.app.provider.table2"
        else -> null
    }

到这里,一个完整的ContentProvider就创建完成了,现在任何一个应用程序都可以使用 ContentResolver访问我们程序中的数据 那么,如何才能保证隐私数据不会泄漏出去呢?其实多亏了ContentProvider的良好机制,这个问题在不知不觉中已经被解决了。

因为所有的增删改查操作都一定要匹配到相应的内容URI格式才能进行,而我们当然不可能向UriMatcher中 添加隐私数据的URI,所以这部分数据根本无法被外部程序访问,安全问题也就不存在了。

实现跨程序数据共享

使用AndroidStudio创建ContentProvider(注意,AndroidStudio可以创建四大组件,ContentProvider和Broadcast位于New->Other下) 在创建时可以填入authority

与之前相同,要操作数据库的话,使用dbHelper,实现对应的方法即可,返回结果cursor对象

代码注意点

override fun onCreate() = context?.let { 
        dbHelper = MyDatabaseHelper(it, "BookStore.db", 2)
        true
    } ?: false

private val uriMatcher by lazy {
        val matcher = UriMatcher(UriMatcher.NO_MATCH)
        matcher.addURI(authority, "book", bookDir)
    }

by lazy代码块是Kotlin提供的一种懒加载技术,代码块中的代码一开始 并不会执行,只有当uriMatcher变量首次被调用的时候才会执行,并且会将代码块中最后一 行代码的返回值赋给uriMatcher。

注意,当访问单条数据的时候,调用了Uri对象的 getPathSegments()方法,它会将内容URI权限之后的部分以“/”符号进行分割,并把分割后 的结果放入一个字符串列表中,那这个列表的第0个位置存放的就是路径,第1个位置存放的就是id了

注意,insert()方法要求返回一个能够表示这条新增数据的URI,所以我们还需要调用 Uri.parse()方法,将一个内容URI解析成Uri对象,当然这个内容URI是以新增数据的id结尾的。

override fun insert(uri: Uri, values: ContentValues?) = dbHelper?.let {
// 添加数据
        val db = it.writableDatabase
        val uriReturn = when (uriMatcher.match(uri)) {
            bookDir, bookItem -> {
                val newBookId = db.insert("Book", null, values)
                Uri.parse("content://$authority/book/$newBookId")
            }
            categoryDir, categoryItem -> {
                val newCategoryId = db.insert("Category", null, values)
                Uri.parse("content://$authority/category/$newCategoryId")
            }
            else -> null
        }
        uriReturn
    }

另外,还有一点需要注意,ContentProvider一定要在AndroidManifest.xml文件中注册才可 以使用。不过幸运的是,我们是使用Android Studio的快捷方式创建的ContentProvider,因 此注册这一步已经自动完成了。

<provider
            android:name=".DatabaseProvider"
            android:authorities="com.example.contactstest"
            android:enabled="true"
            android:exported="true">
</provider>

Kotlin 泛型和委托

泛型的基本用法

示例定义一个泛型类

class MyClass <T> {
	fun method(param: T): T {
		return param
	}
}

只定义一个泛型方法

class MyClass {
	fun <T> method(param: T): T {
		return param
	}
}

调用该方法

// 因为Kotlin有非常出色的类型推断机制,所以此处可以省略类型
val myClass = MyClass()
// val result = myClass.method<Int>(123)
val result = myClass.method(123)

Kotlin还允许我们对泛型的类型进行限制 指定上界的方式来对泛型的类型进行约束,比如这里将method()方法的泛型上界设置为Number类型

class MyClass {
	fun <T: Number> method(param: T): T {
		return param
	}
}

这种写法就表明,我们只能将method()方法的泛型指定成数字类型,比如Int、Float、 Double等。但是如果你指定成字符串类型,就肯定会报错,因为它不是一个数字。

上界即表明此处泛型的类型只能是Number的子类

对以下函数使用泛型进行修改

fun StringBuilder.build(block: StringBuilder.() -> Unit): StringBuilder {
	block()
	return this
}

使用泛型进行修改

fun <T> T.build(block: T.() -> Unit): T {
	block()
	return this
}

类委托和委托属性

委托是一种设计模式,它的基本理念是:操作对象自己不会去处理某段逻辑,而是会把工作委 托给另外一个辅助对象去处理

类委托

核心思想在于将一个类的具体实现委托给另一个类去完成 例如,Set是一个接口,如果要使用它的话,需要使用它具体的实现 类,比如HashSet。而借助于委托模式,我们可以轻松实现一个自己的实现类。比如这里定义一个MySet,并让它实现Set接口

class MySet<T>(private val helperSet: HashSet<T>) : Set<T> {
    override val size: Int
        get() = helperSet.size

    override fun contains(element: T) = helperSet.contains(element)

    override fun containsAll(elements: Collection<T>) =  helperSet.containsAll(elements)

    override fun isEmpty()  = helperSet.isEmpty()

    override fun iterator() = helperSet.iterator()

}

MySet的构造函数中接收了一个HashSet参数,这就相当于一个辅助对象。然后在 Set接口所有的方法实现中,我们都没有进行自己的实现,而是调用了辅助对象中相应的方法实 现,这其实就是一种委托模式

如果我们只是让大部分的方法实现调用辅助对象中的方法,少 部分的方法实现由自己来重写,甚至加入一些自己独有的方法,那么MySet就会成为一个全新 的数据结构类,这就是委托模式的意义所在。

这种写法的弊端,一旦待实现的方法很多的时候,那就会变得很麻烦,代码量巨大 Kotlin通过类委托来解决

Kotlin中委托使用的关键字是by,我们只需要在接口声明的后面使用by关键字,再接上受委托 的辅助对象,就可以免去之前所写的一大堆模板式的代码了

class MySet<T>(private val helperSet: HashSet<T>): Set<T> by helperSet {
}

这两段代码实现的效果是一模一样的,但是借助了类委托的功能之后,代码明显简化了太多。

另外,如果我们要对某个方法进行重新实现,只需要单独重写那一个方法就可以了,其他的方 法仍然可以享受类委托所带来的便利

class MySet<T>(private val helperSet: HashSet<T>): Set<T> by helperSet {

    fun hellWorld() = println("Hello World")

    override fun isEmpty() = false

}
委托属性

类委托的核心思想是将一个类的具体实现委托给另一个类去完成,而委托属性的核心思想是将一个属性(字段)的具体实现委托给另一个类去完成。

委托属性的语法结构

class MyCalss {
	var p by Delegate()
}

可以看到,这里使用by关键字连接了左边的p属性和右边的Delegate实例, 这种写法就代表着将p属性的具体实现委托给了Delegate类去完成。

当调用p属性的时候会自动调用Delegate类的getValue()方法 当给p属性赋值的时候会自动调用Delegate类的setValue()方法。 由此还需要对Delegate类进行具体的实现

class MyClass {
    var p by Delegate()
}

class Delegate {
    private var propValue: Any? = null

    operator fun getValue(myClass: MyClass, prop: KProperty<*>): Any? {
        return propValue
    }

    operator fun setValue(myClass: MyClass, prop: KProperty<*>, value: Any?) {
        propValue = value
    }
}

这是一种标准的代码实现模板,在Delegate类中我们必须实现getValue()和setValue()这 两个方法,并且都要使用operator关键字进行声明。

getValue()方法要接收两个参数:第一个参数用于声明该Delegate类的委托功能可以在什么 类中使用,这里写成MyClass表示仅可在MyClass类中使用;第二个参数 KProperty<*> 是 Kotlin中的一个属性操作类,可用于获取各种属性相关的值,在当前场景下用不着,但是必须在 方法参数上进行声明。

<*> 这种泛型的写法表示你不知道或者不关心泛型的具体类型,只 是为了通过语法编译而已,有点类似于Java中<?>的写法

整个委托属性的工作流程就是这样实现的,现在当我们给MyClass的p属性赋值时,就会调用 Delegate类的setValue()方法,当获取MyClass中p属性的值时,就会调用Delegate类的 getValue()方法。

如果属性p使用 val 关键字定义那么就意味着p属性是无法在初始化之后被重新赋值的,因此也就没有必要实现 setValue()方法,只需要实现getValue()方法就可以了

实现一个自己的lazy函数

始化uriMatcher变量的时候,我们使用了一种懒加载技术。把想要延迟执行的 代码放到by lazy代码块中,这样代码块中的代码在一开始的时候就不会执行,只有当 uriMatcher变量首次被调用的时候,代码块中的代码才会执行。

基本语法结构

val p by lazy {}

只有by才是Kotlin中的关键字,lazy在这里只是一个高阶函数而已。在lazy函数中会创建 并返回一个Delegate对象,当我们调用p属性的时候,其实调用的是Delegate对象的 getValue()方法,然后getValue()方法中又会调用lazy函数传入的Lambda表达式,这样 表达式中的代码就可以得到执行了,并且调用p属性后得到的值就是Lambda表达式中最后一行 代码的返回值。

新建Later.kt文件

import kotlin.reflect.KProperty

class Later<T>(val block: ()-> T) {
    private var value: Any? = null

    operator fun getValue(any: Any?, prop: KProperty<*>): T {
        if (value == null) {
            value = block()
        }
        return value as T
    }
}

fun <T> later(block: () -> T) = Later(block)

**这里将getValue()方法的第一个参数指定成了Any?类型,表示我们希望Later的委托功能在所有类中都可以使用。**然后使用了一个value变量对值进行缓存,如果value为空就调用构造 函数中传入的函数类型参数去获取值,否则就直接返回。

由于懒加载技术是不会对属性进行赋值的,因此这里我们就不用实现setValue()方法了。

运用手机多媒体

连接手机真机,需要打开手机开发者选项,允许USB调试 我自己的手机需要点击文传输之后才可以连接,并且使用的是官方的数据线

使用通知

通知(notification)是Android系统中比较有特色的一个功能,当某个应用程序希望向用户发 出一些提示信息,而该应用程序又不在前台运行时,就可以借助通知来实现。发出一条通知 后,手机最上方的状态栏中会显示一个通知的图标,下拉状态栏后可以看到通知的详细内容。 Android的通知功能自推出以来就大获成功

创建通知渠道

Android 8.0系统引入了通知渠道这个概念。

通知渠道: 每条通知都要属于一个对应的渠道

每个应用程序都可以 自由地创建当前应用拥有哪些通知渠道,但是这些通知渠道的控制权是掌握在用户手上的。用 户可以自由地选择这些通知渠道的重要程度,是否响铃、是否振动或者是否要关闭这个渠道的通知。

拥有了这些控制权之后,用户就再也不用害怕那些垃圾通知的打扰了,因为用户可以自主地选 择关心哪些通知、不关心哪些通知。

以刚才的场景举例,微博就可以创建两种通知渠道,一个 关注,一个推荐。而我作为用户,如果对推荐类的通知不感兴趣,那么我就可以直接将推荐通 知渠道关闭,这样既不影响我接收关心的通知,又不会让那些我不关心的通知来打扰我了。

对于每个应用来说,通知渠道的划分是非常考究的,因为通知渠道一旦创建之后就不能再修改 了,因此开发者需要仔细分析自己的应用程序一共有哪些类型的通知,然后再去创建相应的通知渠道

开始创建通知渠道

  • 首先需要一个NotificationManager对通知进行管理,可以通过调用Context的getSystemService()方法获取。getSystemService()方法接收一个字符串参数用于确定获取系统的哪个服务,这里我们传入Context.NOTIFICATION_SERVICE即可
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
  • 使用NotificationChannel类构建一个通知渠道并调用NotificationManager的createNotificationChannel()方法完成创建 由于NotificationChannel类和 createNotificationChannel()方法都是Android 8.0系统中新增的API,因此我们在使用 的时候还需要进行版本判断才可以
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
	// 此处Build.VERSION_CODE.O 最后一个字母为大写O不是数字0
	val channel = NotificationChannel(channelId, channelName, importance)
	manager.createNotificationChannel(channel)
}

创建一个通知渠道至少需要渠道ID渠道名称以及重要等级这3个参数

  • 渠道ID可以随便定义,只要保证全局唯一性就可以
  • 渠道名称是给用户看的,需要可以清楚地表达这个渠道的用途
  • 通知的重要等级主要有IMPORTANCE_HIGH、IMPORTANCE_DEFAULT、IMPORTANCE_LOW、IMPORTANCE_MIN

当然这里只是初始状态下的重要等级,用户可以随时手动更改某个通知渠道的重要等级,开发者是无法干预的

通知的基本用法

通知的用法还是比较灵活的,既可以在Activity里创建,也可以在BroadcastReceiver里创建,也能在Service里创建

相比于BroadcastReceiver和Service,在Activity里创建通知的场景还是比较少的,因为一般只有当程序进入后台的时候才需要使用通知

首先需要使用一个Builder构造器来创建Notification对象

使用AndroidX库中提供的兼容API AndroidX库中提供了一个NotificationCompat类,使用这个类的构造器创建Notification对象,就可以保证我们的程序在所有Android系统版本上都能正常工作了

val notification = NotificationCompat.Builder(context, channelId).build()

NotificationCompat.Builder的构造函数中接收两个参数:第一个参数是context,这个 没什么好说的;第二个参数是渠道ID,需要和我们在创建通知渠道时指定的渠道ID相匹配才行。

当然,上述代码只是创建了一个空的Notification对象,并没有什么实际作用,我们可以在 最终的build()方法之前连缀任意多的设置方法来创建一个丰富的Notification对象,先来看一些最基本的设置

val notification = NotificationCompat.Builder(context, channelId)
	.setContentTitle("This is content title")
	.setContentText("This is content text")
	.setSmallIcon(R.id.drawable.small_icon)
	.setLargeIcon(BitmapFactory.decodeResource(getRseources(), R.drawable.large_icon))
	.build()

上述代码中一共调用了4个设置方法,下面我们来一一解析一下。setContentTitle()方法用 于指定通知的标题内容,下拉系统状态栏就可以看到这部分内容。setContentText()方法用 于指定通知的正文内容,同样下拉系统状态栏就可以看到这部分内容。setSmallIcon()方法 用于设置通知的小图标,注意,只能使用纯alpha图层的图片进行设置,小图标会显示在系统状 态栏上。setLargeIcon()方法用于设置通知的大图标,当下拉系统状态栏时,就可以看到设 置的大图标了。

以上工作都完成之后,只需要调用NotificationManager的notify()方法就可以让通知显示 出来了。notify()方法接收两个参数:第一个参数是id,要保证为每个通知指定的id都是不同的

第二个参数则是Notification对象,这里直接将我们刚刚创建好的Notification对 象传入即可。

manager.notify(1, notification)

创建NotificationTest项目 修改activity_main.xml文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/sendNotice"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Send Notice"
        android:textAllCaps="false"
        />

</LinearLayout>

修改MainActivity中的代码


import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.core.app.NotificationCompat
import com.example.notificationtest.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var bind: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)
        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel("normal", "Normal", NotificationManager.IMPORTANCE_DEFAULT)
            manager.createNotificationChannel(channel)
        }
        bind.sendNotice.setOnClickListener {
            val notification = NotificationCompat.Builder(this, "normal")
                .setContentTitle("This is content title")
                .setContentText("This is content text")
                .setSmallIcon(R.drawable.small_icon)
                .setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.large_icon))
                .build()
            manager.notify(1, notification)
        }
    }
}
注意某些系统可能会有通知过滤(比如我自己使用的小米,默认设置会把通知给过滤掉)需要系统设置为不折叠过滤该应用所有通知

如果需要通知设置为可点击这就涉及了一个新的概念——PendingIntent

PendingIntent从名字上看起来就和Intent有些类似,它们确实存在不少共同点。比如它们都可 以指明某一个“意图”,都可以用于启动Activity、启动Service以及发送广播等。不同的是, Intent倾向于立即执行某个动作,而PendingIntent倾向于在某个合适的时机执行某个动作。所 以,也可以把PendingIntent简单地理解为延迟执行的Intent

PendingIntent的用法同样很简单,它主要提供了几个静态方法用于获取PendingIntent的实 例,可以根据需求来选择是使用getActivity()方法、getBroadcast()方法,还是 getService()方法。这几个方法所接收的参数都是相同的

  1. 第一个参数依旧是Context
  2. 第二个参数一般用不到,传入0即可
  3. 第三个参数是一个Intent对象,我们可以通过这个对象构建出PendingIntent的“意图”
  4. 第四个参数用于确定PendingIntent的行为,有FLAG_ONE_SHOT、FLAG_NO_CREATE、FLAG_CANCEL_CURRENT和FLAG_UPDATE_CURRENT

每种值的具体含义你可以查看文档,通常情况下这个参数传入0就可以了

修改NotificationTest,使得通知能打开一个新的Activity 创建Activity,修改activity_notification.xml文件

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".NotificationActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:text="This is notification layout"
        />

</RelativeLayout>

修改MainActivity文件

bind.sendNotice.setOnClickListener {
            // 创建Intent
            val intent = Intent(this, NotificationActivity::class.java)
            // 创建PendingIntent
            val pi = PendingIntent.getActivity(this, 0, intent, 0)
            val notification = NotificationCompat.Builder(this, "normal")
                .setContentTitle("This is content title")
                .setContentText("This is content text")
                .setSmallIcon(R.drawable.small_icon)
                .setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.large_icon))
                // 此处设置intent
                .setContentIntent(pi)
                .build()
            manager.notify(1, notification)
        }

咦?怎么系统状态上的通知图标还没有消失呢? 是这样的,如果我们没有在代码中对该通知进行取消,它就会一直显示在系统的状态栏上。

解决方法有两种

  1. 在NotificationCompat.Builder中再连缀一个setAutoCancel()方法
  2. 一种是显式地调用NotificationManager的cancel()方法将它取消

第一种方法

val notification = NotificationCompat.Builder(this, "normal")
	...
	.setAutoCancel(true)
	.build()

第二种方法

class NotificationActivity : AppCompatActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContentView(R.layout.activity_notification)
		val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
		manager.cancel(1)
	}
}

这里我们在cancel()方法中传入了1,这个1是什么意思呢?还记得在创建通知的时候给每条 通知指定的id吗?当时我们给这条通知设置的id就是1。因此,如果你想取消哪条通知,在 cancel()方法中传入该通知的id就行了。(目前这个方法在我自己手机上没用Andorid11 红米K20Pro)

通知的进阶技巧

NofiticationCompat下的各种Style

实际上,NotificationCompat.Builder中提供了非常丰富的API,以便我们创建出更加多样的通知效果

setStyle()方法接收一个NotificationCompat.Style参数,这个参数就是用来构建具体的富文本信息的,如长文字、图片等。

如果setContentText内容过长,通知会出现省略号, 可以使用setStyle()方法,传入NotificationCompat.BigTextStyle().bigText("ssss")

val notification = NotificationCompat.Builder(this, "normal")
                .setContentTitle("This is content title")
                .setStyle(NotificationCompat.BigTextStyle().bigText("Learn how to build\n" +
                        "notifications, send and sync data, and use voice actions. Get the official\n" +
                        "Android IDE and developer tools to build apps for Android."))
                .setSmallIcon(R.drawable.small_icon)
                .setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.large_icon))
                // 此处设置intent
                .setContentIntent(pi)
                .setAutoCancel(true)
                .build()
            manager.notify(1, notification)
因为不同的手机厂商的不同系统会有不同的通知设置,我的手机默认使用经典模式样式的通知,这个模式下,Style的样式不起作用,但是在切换到原生样式之后通知的Style就可以正常显示了

显示大图片

setStyle(NotificationCompat.BigPictureStyle().bigPicture(
	BitmapFactory.decodeResource(resources, R.drawable.big_image)
))
通知渠道的重要等级

通知渠道的重要等级越高,发出的通知就越容易获得用户的注意。 比如高重要等级的通知渠道发出的通知可以弹出横幅、发出声音,而低重要等级的通知渠道发出的通知不仅可能 会在某些情况下被隐藏,而且可能会被改变显示的顺序,将其排在更重要的通知之后。

需要注意的是,开发者只能在创建通知渠道的时候为它指定初始的重要等级,如果用户不认 可这个重要等级的话,可以随时进行修改,开发者对此无权再进行调整和变更,因为通知渠道一旦创建就不能再通过代码修改了

无法修改之前创建的通知渠道,只能重新创建 修改MainActivity中的代码

val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
	    // channel name的字符串参数在手机上可见
            val channel = NotificationChannel("important", "Important", NotificationManager.IMPORTANCE_HIGH)
            manager.createNotificationChannel(channel)
        }

        bind.importantNotice.setOnClickListener {
            val intent = Intent(this, NotificationActivity::class.java)
            val pi = PendingIntent.getActivity(this, 0, intent, 0)
            val notification = NotificationCompat.Builder(this, "important")
                .setContentTitle("This is content title")
                .setSmallIcon(R.drawable.small_icon)
                .setStyle(
                    NotificationCompat.BigPictureStyle().bigPicture(
                        BitmapFactory.decodeResource(resources, R.drawable.big_image)
                    )
                )
                .setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.large_icon))
                // 此处设置intent
                .setContentIntent(pi)
                .setAutoCancel(true)
                .build()
            manager.notify(1, notification)
        }
我自己的手机需要在设置中设置对应Channel名称的通知允许弹出通知框
### 调用摄像头和相册 #### 调用摄像头拍照 创建CameraAlbumTest项目,修改activity_main.xml文件 ```xml
<Button
    android:id="@+id/takePhotoBtn"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Take Photo"
    android:textAllCaps="false"
    />

<ImageView
    android:id="@+id/imageView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center_horizontal"
    />
``` 修改MainActivity文件 ```kotlin import android.app.Activity import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import android.media.ExifInterface import android.net.Uri import android.os.Build import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import android.provider.MediaStore import androidx.core.content.FileProvider import com.example.cameraalbumtest.databinding.ActivityMainBinding import java.io.File

class MainActivity : AppCompatActivity() {

private val takePhoto = 1
private lateinit var imageUri: Uri
private lateinit var outputImage: File
private lateinit var bind: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    if (!::bind.isInitialized) {
        bind = ActivityMainBinding.inflate(layoutInflater)
    }
    setContentView(bind.root)

    bind.takePhotoBtn.setOnClickListener {
        // 创建File对象,用于存储拍照后的照片
        outputImage = File(externalCacheDir, "output_image.jpg")
        if (outputImage.exists()) {
            outputImage.delete()
        }
        outputImage.createNewFile()
        imageUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            FileProvider.getUriForFile(
                this,
                "com.examplecameraalbumtest.fileprovider",
                outputImage
            )
        } else {
            Uri.fromFile(outputImage)
        }
        // 启动相机程序
        val intent = Intent("android.media.action.IMAGE_CAPTURE")
        intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri)
        startActivityForResult(intent, takePhoto)
    }
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    when (requestCode) {
        takePhoto -> {
            if (resultCode == Activity.RESULT_OK) {
                // 将拍摄的照片显示出来
                val bitmap =
                    BitmapFactory.decodeStream(contentResolver.openInputStream(imageUri))
                bind.imageView.setImageBitmap(rotateIfRequired(bitmap))
            }
        }
    }
}

private fun rotateIfRequired(bitmap: Bitmap): Bitmap {
    val exif = ExifInterface(outputImage.path)
    return when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
        ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90)
        ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180)
        ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270)
        else -> bitmap
    }
}

private fun rotateBitmap(bitmap: Bitmap, degree: Int): Bitmap {
    val matrix = Matrix()
    matrix.postRotate(degree.toFloat())
    val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
    bitmap.recycle() // 将不需要的bitmap对象回收
    return rotatedBitmap
}

}


首先这里创建了一个File对象,用于存放摄像头拍下的图片,这里我们把图片命名为
output_image.jpg,并存放在手机SD卡的应用关联缓存目录下。什么叫作应用关联缓存目录
呢?就是指SD卡中专门用于存放当前应用缓存数据的位置,调用getExternalCacheDir()
方法可以得到这个目录,具体的路径是`/sdcard/Android/data/<package name>/cache`。
那么为什么要使用应用关联缓存目录来存放图片呢?因为从Android 6.0系统开始,读写SD卡
被列为了危险权限,如果将图片存放在SD卡的任何其他目录,都要进行运行时权限处理才行,
而使用应用关联目录则可以跳过这一步


接着会进行一个判断,如果运行设备的系统版本低于Android 7.0,就调用Uri的fromFile()
方法将File对象转换成Uri对象,这个Uri对象标识着output_image.jpg这张图片的本地真实
路径。否则,就调用FileProvider的getUriForFile()方法将File对象转换成一个封装过的
Uri对象。getUriForFile()方法接收3个参数:第一个参数要求传入Context对象,第二个
参数可以是任意唯一的字符串,第三个参数则是我们刚刚创建的File对象。之所以要进行这样
一层转换,是因为从Android 7.0系统开始,直接使用本地真实路径的Uri被认为是不安全的,
会抛出一个FileUriExposedException异常。而FileProvider则是一种特殊的
ContentProvider,它使用了和ContentProvider类似的机制来对数据进行保护,可以选择性
地将封装过的Uri共享给外部,从而提高了应用的安全性


接下来构建了一个Intent对象,并将这个Intent的action指定为
android.media.action.IMAGE_CAPTURE,再调用Intent的putExtra()方法指定图片的
输出地址,这里填入刚刚得到的Uri对象,最后调用startActivityForResult()启动
Activity。由于我们使用的是一个隐式Intent,系统会找出能够响应这个Intent的Activity去启
动,这样照相机程序就会被打开,拍下的照片将会输出到output_image.jpg中。


需要注意的是,调用照相机程序去拍照有可能会在一些手机上发生照片旋转的情况。这是因为
这些手机认为打开摄像头进行拍摄时手机就应该是横屏的,因此回到竖屏的情况下就会发生90
度的旋转。为此,这里我们又加上了判断图片方向的代码,如果发现图片需要进行旋转,那么
就先将图片旋转相应的角度,然后再显示到界面上。


在AndroidManifest.xml文件中注册ContentProvider
在activity标签同级添加provider标签
在与application标签同级下添加user-permission标签
```xml

<uses-permission android:name="android.permission.CAMERA"/>

<provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="com.example.cameraalbumtest.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>

在res/xml文件夹下创建file_paths.xml文件加入如下内容

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path
        name="my_images"
        path="/"/>
</paths>

从相册中选择图片

在activity_main.xml文件中添加一个按钮 在MainActivity中添加监听事件

bind.fromAlbumBtn.setOnClickListener {
            // 打开文件选择器
            val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
            intent.addCategory(Intent.CATEGORY_OPENABLE)
            // 指定只显示图片
	    // 注意type的字符串别打错了
            intent.type = "image/*"
            startActivityForResult(intent, fromAlbum)
        }

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        when (requestCode) {
            takePhoto -> {
                if (resultCode == Activity.RESULT_OK) {
                    // 将拍摄的照片显示出来
                    val bitmap =
                        BitmapFactory.decodeStream(contentResolver.openInputStream(imageUri))
                    bind.imageView.setImageBitmap(rotateIfRequired(bitmap))
                }
            }
            fromAlbum -> {
                if (resultCode == Activity.RESULT_OK && data != null) {
                    data.data?.let {
                        // 将选择的图片显示
                        val bitmap = getBitmapFromUri(it)
                        bind.imageView.setImageBitmap(bitmap)
                    }
                }
            }
        }
    }

private fun getBitmapFromUri(uri: Uri) = contentResolver.openFileDescriptor(uri, "r").use {
        BitmapFactory.decodeFileDescriptor(it?.fileDescriptor)
    }

播放多媒体文件

播放音频

在Android中播放音频文件一般是使用MediaPlayer类实现的 MediaPlayer类中常用的控制方法

方法名功能描述
setDataSource()设置要播放的音频文件的位置
prepare()在开始播放之前调用,完成准备工作
start()开始或继续播放音频
pause()暂停播放音频
reset()将MediaPlayer对象重置到刚刚创建的状态
seekTo()从指定位置开始播放音频
stop()停止播放音频
release()释放与MediaPlayer对象相关的资源
isPlaying()判断当前MediaPlayer是否正在播放音频
getDuration()获取载入的音频文件的时长

MediaPlayer工作流程

flowchart LR
s1([创建MediaPlayer对象]) --> s2(["调用setDataResource()方法设置音频文件路径"])
s2 --> s3(["调用prepare()方法"])
s3 --> s4(["调用start()方法"])

如果调用pause()方法就会暂停播放,调用reset()方法就会停止播放

创建PlayAudioTest项目 在activity_main.xml文件内添加对应按钮

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/play"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Play" />
    <Button
        android:id="@+id/pause"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Pause" />
    <Button
        android:id="@+id/stop"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Stop" />

</LinearLayout>

MediaPlayer可以用于播放网络、本地以及应用程序安装包中的音频。

Android Studio允许我们在项目工程中创建一个assets目录,并在这个目录下存放任意文件和 子目录,这些文件和子目录在项目打包时会一并被打包到安装文件中,然后我们在程序中就可 以借助AssetManager这个类提供的接口对assets目录下的文件进行读取
assets目录必须创建在app/src/main这个目录下面,和java、res这两个目录是平级的

修改MainActivity代码

import android.media.MediaPlayer
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.playaudiotest.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var bind: ActivityMainBinding
    private val mediaPlayer = MediaPlayer()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)

        initMediaPlayer()

        bind.play.setOnClickListener {
            if (!mediaPlayer.isPlaying) {
                mediaPlayer.start() // 开始播放
            }
        }

        bind.pause.setOnClickListener {
            if (mediaPlayer.isPlaying) {
                mediaPlayer.pause()
            }
        }

        bind.stop.setOnClickListener {
            /**
             * 如果当前MediaPlayer正在播放音频,则调用reset()方法将MediaPlayer重置为
             * 刚刚创建的状态,然后重新调用一遍initMediaPlayer()方法
             */
            if (mediaPlayer.isPlaying) {
                mediaPlayer.reset() // 停止播放
                initMediaPlayer()
            }
        }
    }

    private fun initMediaPlayer() {
        val assetManager = assets
        val fd = assetManager.openFd("music.mp3")
        mediaPlayer.setDataSource(fd.fileDescriptor, fd.startOffset, fd.length)
        mediaPlayer.prepare()
    }

    override fun onDestroy() {
        super.onDestroy()
        mediaPlayer.stop()
        mediaPlayer.release()
    }
}

播放视频

播放视频文件其实并不比播放音频文件复杂,主要是使用VideoView类来实现的。 VideoView的用法和MediaPlayer也比较类似

VideoView常用方法

方法名功能描述
setVideoPath()设置要播放的视频文件的位置
start()开始或继续播放视频
pause()暂停播放视频
resume()将视频从头开始播放
seekTo()从指定位置开始播放视频
isPlaying()判断当前是否正在播放视频
getDuration()获取载入的视频文件的时长
suspend()释放VideView所占用的资源

新建PlayVideoTest项目 修改activity_main.xml中的代码

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <Button
            android:id="@+id/play"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="Play" />
        <Button
            android:id="@+id/pause"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="Pause" />
        <Button
            android:id="@+id/replay"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="Replay" />

    </LinearLayout>

    <VideoView
        android:id="@+id/videoView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

接下来的问题就是存放视频资源了,很可惜的是,VideoView不支持直接播放assets目录下的 视频资源,所以我们只能寻找其他的解决方案

res目录下允许我们再创建一个raw目录,像诸如音频、视频之类的资源文件也可以放在这里, 并且VideoView是可以直接播放这个目录下的视频资源的。

修改MainActivity中的代码

其实VideoView只是帮我们做了一个很好的封装而已,它的背后仍 然是使用MediaPlayer对视频文件进行控制的。另外需要注意,VideoView并不是一个万能的 视频播放工具类,它在视频格式的支持以及播放效率方面都存在着较大的不足

Kotlin 使用infix函数构建更可读的语法

首先,to并不是Kotlin语言中的一个关键字,之所以我们能够使用A to B这样的语法结构,是 因为Kotlin提供了一种高级语法糖特性:infix函数。

infix 只是把编程语言函数调用的语法规则调整了一下而已,比如A to B这样的写法,实际 上等价于A.to(B)的写法。

修改String类中的startsWith方法

// 原版
if ("Hello Koltin".startsWith("Hello")){
	// 具体逻辑
}

// 使用infix编写
infix fun String.beginWith(prefix: String) = startsWith(prefix)

// 实际使用
if ("Hello Kotlin" beginsWith "Hello") {
	// 具体逻辑
}

infix函数允许我们将函数调用时的小数点、括号等计算机相关的语法去掉,从而使用一种更 接近英语的语法来编写程序,让代码看起来更加具有可读性。

另外,infix函数由于其语法糖格式的特殊性,有两个比较严格的限制

  1. infix函数是不能定义成顶层函数的,它必须是某个类的成员函数,可以使用扩展函数的方式将它定义到某个类当中
  2. infix函数必须接收且只能接收一个参数,至于参数类型是没有限制的。只有同时满足这两点,infix函数的语法糖才具备使用的条件
infix fun <T> Collection<T>.has(element: T) = contains(element)

可以看到,我们给Collection接口添加了一个扩展函数,这是因为Collection是Java以及 Kotlin所有集合的总接口,因此给Collection添加一个has()函数,那么所有集合的子类就都 可以使用这个函数了。

infix fun <T> Collection<T>.has(element: T) = contains(element)

fun main() {
    val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
    if (list has "Banana") {
        // 具体逻辑
    }
}

实现to的作用

infix fun <A, B> A.with(that: B): Pair<A, B> = Pair(this, that)

val map = mapOf("apple" with 1, "banana" with 2, "orange" with 3, "pear" with 4, "grape" with 5)

Git版本控制工具进阶

忽略文件

.gitignore 在项目根目录下输入git init 初始化git 控制

查看修改内容

git status

修改文件之后使用该命令查看

(base)   PlayVideoTest git:(master)  git status
位于分支 master
尚未暂存以备提交的变更:
  (使用 "git add <文件>..." 更新要提交的内容)
  (使用 "git restore <文件>..." 丢弃工作区的改动)
        修改:     app/src/main/java/com/example/playvideotest/MainActivity.kt

未跟踪的文件:
  (使用 "git add <文件>..." 以包含要提交的内容)
        .idea/vcs.xml

修改尚未加入提交(使用 "git add" 和/或 "git commit -a")

查看文件的变动

git diff

如果只想看某个文件的不同,之后可以跟上文件名

撤销未提交的修改

git checkout # 后跟文件名

可以看到,当前项目中没有任何可提交的文件,说明撤销操作确实成功了。

不过,这种撤销方式只适用于那些还没有执行过add命令的文件,如果某个文件已经被添加过 了,这种方式就无法撤销更改的内容

对于已添加的文件(使用了git add),我们应该先对其取消添加,然后才可以撤回提交

git reset HEAD # 后跟文件名

之后可以用git checkout 撤回修改

查看提交记录

可以使用log命令查看历史提交信息

git log

重新查看提交记录

git log # 后跟记录id

查看指定最近几次的提交,-1表示最后一次提交

git log -1

Service

Service 是什么

Service是Android中实现程序后台运行的解决方案,它非常适合执行那些不需要和用户交互而 且还要求长期运行的任务。Service的运行不依赖于任何用户界面,即使程序被切换到后台,或 者用户打开了另外一个应用程序,Service仍然能够保持正常运行

不过Service并不是运行在独立的进程当中,而是依赖于创建Service时所在的应用程序进程。当某个应用程序进程被杀掉时,所有依赖于该进程的Service也会停止运行。

也不要被Service的后台概念所迷惑,实际上Service并不会自动开启线程,所有的代码 都是默认运行在主线程当中的。也就是说,我们需要在Service的内部手动创建子线程,并在这 里执行具体的任务,否则就有可能出现主线程被阻塞的情况

Android多线程编程

线程的基本用法

定义一个类继承Thread并且重写run方法

class MyThread : Thread() {
    override fun run() {
        // 具体逻辑
    }
}

启动线程
MyThread().start()

使用Runnable接口

class MyThread2: Runnable {
    override fun run() {
        // 具体逻辑
    }
}

val myThread = MyThread2()
Thread(myThread).start()

// 使用Lambda形式
Thread {
	// 具体逻辑
}.start()

Kotlin中提供了thread 顶层函数开启线程

thread {
	// 编写具体逻辑
}

在子线程中更新UI

和许多其他的GUI库一样,Android的UI也是线程不安全的。也就是说,如果想要 更新应用程序里的UI元素,必须在主线程中进行,否则就会出现异常。

修改activity_main.xml文件

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/changeTextBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Change Text" />
    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:text="Hello world"
        android:textSize="20sp" />

</RelativeLayout>

在MainActivity中修改文字

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.androidthreadtest.databinding.ActivityMainBinding
import kotlin.concurrent.thread

class MainActivity : AppCompatActivity() {

    private lateinit var bind: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)
        bind.changeTextBtn.setOnClickListener {
            thread {
                bind.textView.text = "Nice to meet you"
            }
        }
    }
}

直接报错闪退 报错信息

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

意思为只有在原始线程(创建view的线程)中才能修改view

Android提供了一套异步消息处理机制,完美地解决了在子线程中进行UI操作的 问题。下一步分再来讨论

修改MainActivity代码

private lateinit var bind: ActivityMainBinding

    private val updateText = 1

    val handler = object: Handler(Looper.getMainLooper()) {
        override fun handleMessage(msg: Message) {
            super.handleMessage(msg)
            // 可以在这里执行UI操作
            when (msg.what) {
                updateText -> bind.textView.text = "Nice to meet you"
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)
        bind.changeTextBtn.setOnClickListener {
            thread {
                val msg = Message()
                msg.what = updateText
                handler.sendMessage(msg) // 将Message对象发送出去
            }
        }
    }

创建了一个Message(android.os.Message)对象,并将它的 what字段的值指定为updateText,然后调用Handler的sendMessage()方法将这条 Message发送出去。很快,Handler就会收到这条Message,并在handleMessage()方法中 对它进行处理。

注意此时handleMessage()方法中的代码就是在主线程当中运行的了,所以我们可以放 心地在这里进行UI操作。

这样你就已经掌握了Android异步消息处理的基本用法,使用这种机制就可以出色地解决在子线 程中更新UI的问题。

目前我个人的理解:在其他线程中发送消息给要更新UI的线程,对应UI元素所在的线 程更新UI界面

解析异步消息处理机制

Android中的异步消息处理主要由4个部分组成:Message、Handler、MessageQueue和 Looper。

  1. Message Message是在线程之间传递的消息,它可以在内部携带少量的信息,用于在不同线程之间 传递数据。上一小节中我们使用到了Message的what字段,除此之外还可以使用arg1和 arg2字段来携带一些整型数据,使用obj字段携带一个Object对象。

  2. Handler Handler顾名思义也就是处理者的意思,它主要是用于发送和处理消息的。发送消息一般 是使用Handler的sendMessage()方法、post()方法等,而发出的消息经过一系列地辗 转处理后,最终会传递到Handler的handleMessage()方法中。

  3. MessageQueue MessageQueue是消息队列的意思,它主要用于存放所有通过Handler发送的消息。这部 分消息会一直存在于消息队列中,等待被处理。每个线程中只会有一个MessageQueue对象

  4. Looper Looper是每个线程中的MessageQueue的管家,调用Looper的loop()方法后,就会进入 一个无限循环当中,然后每当发现MessageQueue中存在一条消息时,就会将它取出,并 传递到Handler的handleMessage()方法中。每个线程中只会有一个Looper对象。

异步处理消息的流程

  1. 首先需要在主线程当中创建一个Handler对象,并重写handleMessage()方法
val handler = object: Handler(Looper.getMainLoop()) {
	override fun handleMessagee(msg: Nessage) {
		super.handlerMessage(msg)
	}
}
  1. 在子线程中创建Message对象,通过Handler将消息发出
val msg = Message()
msg.what = yourMessage
handler.sendMessage(msg)
  1. 后这条消息会被添加到MessageQueue的队列中等待被处理
  2. 而Looper则会一直尝试从MessageQueue中取出待处理消息,最后分发回Handler的 handleMessage()方法中 由于Handler的构造函数中我们传入了Looper.getMainLooper(),所以此时handleMessage()方法中的代码也会在主线程中运 行,于是我们在这里就可以安心地进行UI操作了

异步消息处理机制流程图

异步消息处理机制流程图
#### 使用AsyncTask 不过为了更加方便我们在子线程中对UI进行操作,Android还提供了另外一些好用的工具,比如 AsyncTask。借助AsyncTask,即使你对异步消息处理机制完全不了解,也可以十分简单地从 子线程切换到主线程。

AsyncTask 的基本用法 AsyncTask是一个抽象类, 需要自己写一个类去继承它, AsyncTask指定了3个泛型参数 三个参数用途分别如下

  • Params 在执行AsyncTask时需要传入的参数,可用于在后台任务中使用
  • Progress 在后台任务执行时,如果需要在界面上显示当前的进度,则使用这里指定的泛型作为进度单位
  • Result 当任务执行完毕后,如果需要对结果进行返回,则使用这里指定的泛型作为返回值类型。
class DownloadTask: AsyncTask<Unit, Int, Boolean> {

}

这里我们把AsyncTask的第一个泛型参数指定为Unit,表示在执行AsyncTask的时候不需要传 入参数给后台任务。第二个泛型参数指定为Int,表示使用整型数据来作为进度显示单位。第三 个泛型参数指定为Boolean,则表示使用布尔型数据来反馈执行结果

还需要重写AsyncTask中的几个方法才能完成对任务的定制。经常需要重写的方法有以下4个

  1. onPreExecute() 这个方法会在后台任务开始执行之前调用,用于进行一些界面上的初始化操作,比如显示 一个进度条对话框等。

  2. doInBackground(Params...) 这个方法中的所有代码都会在子线程中运行,我们应该在这里去处理所有的耗时任务。任 务一旦完成,就可以通过return语句将任务的执行结果返回,如果AsyncTask的第三个泛 型参数指定的是Unit,就可以不返回任务执行结果。注意,在这个方法中是不可以进行UI操作的, 如果需要更新UI元素,比如说反馈当前任务的执行进度,可以调用publishProgress (Progress...)方法来完成。

  3. onProgressUpdate(Progress...) 当在后台任务中调用了publishProgress(Progress...)方法后,onProgressUpdate (Progress...)方法就会很快被调用, 该方法中携带的参数就是在后台任务中传递过来的。在这个方法中可以对UI进行操作,利用参数中的数值就可以对界面元素进行相应的更新。

  4. onPostExecute(Result) 当后台任务执行完毕并通过return语句进行返回时,这个方法就很快会被调用。返回的数 据会作为参数传递到此方法中,可以利用返回的数据进行一些UI操作,比如说提醒任务执 行的结果,以及关闭进度条对话框等。

class DownloadTask : AsyncTask<Unit, Int, Boolean>() {
    override fun onPreExecute() {
        progressDialog.show() // 显示进度对话框
    }

    override fun doInBackground(vararg params: Unit?) = try {
        while (true) {
            val downloadPercent = doDownload() // 这是一个虚构的方法
            publishProgress(downloadPercent)
            if (downloadPercent >= 100) {
                break
            }
        }
        true
    } catch (e: Exception) {
        false
    }

    override fun onProgressUpdate(vararg values: Int?) {
	// 在这里更新下载进度
        progressDialog.setMessage("Downloaded ${values[0]}%")
    }

    override fun onPostExecute(result: Boolean) {
        progressDialog.dismiss()// 关闭进度对话框
	// 在这里提示下载结果
        if (result) {
            Toast.makeText(context, "Download succeeded", Toast.LENGTH_SHORT).show()
        } else {
            Toast.makeText(context, " Download failed", Toast.LENGTH_SHORT).show()
        }
    }
}

启动这个任务

DownloadTask().execute()

当然,你也可以给execute()方法传入任意数量的参数,这些参数将会传递到DownloadTask 的doInBackground()方法当中

我们并不需要去考虑什么异步消息处理机制,也不需要专门使用一个Handler来发送和接收消息,只需要调用一下 publishProgress()方法,就可以轻松地从子线程切换到UI线程了。

即onProgressUpdate方法实在UI线程上执行的

Service的基本用法

定义一个Service

创建ServiceTest项目 然后使用AndroidStudio创建Service

在MyService内有如下代码

import android.app.Service
import android.content.Intent
import android.os.IBinder

class MyService : Service() {

    override fun onBind(intent: Intent): IBinder {
        TODO("Return the communication channel to the service.")
    }
}

可以看到,MyService是继承自系统的Service类的。目前MyService中可以算是空空如也, 但有一个onBind()方法特别醒目。这个方法是Service中唯一的抽象方法,所以必须在子类里实现。

执行自己的逻辑需要重写一些其他方法,

class MyService : Service() {

    override fun onBind(intent: Intent): IBinder {
        TODO("Return the communication channel to the service.")
    }

    override fun onCreate() {
        super.onCreate()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        return super.onStartCommand(intent, flags, startId)
    }

    override fun onDestroy() {
        super.onDestroy()
    }

}

重写了onCreate()、onStartCommand()和onDestroy()这3个方法,它们是每个Service中最常用到的3个方法了。

其中onCreate()方法会在Service创建的时候调用,onStartCommand()方法会在每次Service启动的时候调用, onDestroy()方法会在Service销毁的时候调用

另外需要注意,每一个Service都需要在AndroidManifest.xml文件中进行注册才能生效 (四大组件都需要在AndroidManifest.xml文件中注册), 使用AndroidStudio创建的Service,AndroidStudio会自动帮我们在AndroidManifest.xml文件中注册

启动和停止Service

修改activity_main.xml文件,添加按钮 修改MainActivity中的代码

class MainActivity : AppCompatActivity() {

    private lateinit var bind: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)

        bind.startServiceBtn.setOnClickListener {
            val intent = Intent(this, MyService::class.java)
            startService(intent) // 启动Service
        }

        bind.stopServiceBtn.setOnClickListener {
            val intent = Intent(this, MyService::class.java)
            stopService(intent) // 停止Service
        }
    }
}

MyService中的代码加入日志

class MyService : Service() {

    override fun onBind(intent: Intent): IBinder {
	// 此处也写出了次方法返回通信通道
        TODO("Return the communication channel to the service.")
    }

    override fun onCreate() {
        super.onCreate()
        Log.d("MyService", "onCreate executed")
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.d("MyService", "onStartCommand executed")
        return super.onStartCommand(intent, flags, startId)
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d("MyService", "onDestroy executed")
    }

}
onCreate和onStartCommand

onCreate()方法是在Service第一次创建的时候调用的,而onStartCommand()方法则 在每次启动Service的时候都会调用

Activity和Service进行通信

虽然Service是在Activity里启动的,但是在启动了Service之后,Activity与Service基本就没有什么关系了。 确实如此,我们在Activity里调用了startService()方法来启动MyService,然后 MyService的onCreate()和onStartCommand()方法就会得到执行。之后Service会一直处 于运行状态,但具体运行的是什么逻辑,Activity就控制不了了。

比如说,目前我们希望在MyService里提供一个下载功能,然后在Activity中可以决定何时开始 下载,以及随时查看下载进度。实现这个功能的思路是创建一个专门的Binder对象来对下载功能进行管理。

修改MyService中的代码

class MyService : Service() {

    private val mBinder = DownloadBinder()

    class DownloadBinder: Binder() {
        fun startDownload() {
            Log.d("MyService", "startDownload executed")
        }
        fun getProgress(): Int {
            Log.d("MyService", "getProgress executed")
            return 0
        }
    }

    override fun onBind(intent: Intent): IBinder {
        return mBinder
    }
    ...
}

在布局文件中新增两个按钮

这两个按钮分别是用于绑定和取消绑定Service的, 此处是与Activity进行绑定 当一个Activity和Service绑定了之后,就可以调用该Service里的Binder提供的 方法了

修改MainActivity代码

private lateinit var downloadBinder: MyService.DownloadBinder

    private val connection = object: ServiceConnection {
        override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
            downloadBinder = service as MyService.DownloadBinder
            downloadBinder.startDownload()
            downloadBinder.getProgress()
        }

        override fun onServiceDisconnected(name: ComponentName?) {
            TODO("Not yet implemented")
        }
    }

bind.bindServiceBtn.setOnClickListener {
            val intent = Intent(this, MyService::class.java)
            bindService(intent, connection, Context.BIND_AUTO_CREATE) // 绑定Service
        }

        bind.stopServiceBtn.setOnClickListener {
            unbindService(connection)
        }

onServiceConnected()方法方法会在Activity与Service成功绑定的时候调用,而 onServiceDisconnected()方法只有在Service的创建进程崩溃或者被杀掉的时候才会调 用,这个方法不太常用。

这里传入BIND_AUTO_CREATE表示在Activity和Service进行绑定后自动创建Service。这会使得MyService中的onCreate()方法得到执行,但 onStartCommand()方法不会执行。

另外需要注意,任何一个Service在整个应用程序范围内都是通用的,即MyService不仅可以和 MainActivity绑定,还可以和任何一个其他的Activity进行绑定,而且在绑定完成后,它们都可 以获取相同的DownloadBinder实例

Service的生命周期

一旦在项目的任何位置调用了Context的startService()方法,相应的Service就会启动, 并回调onStartCommand()方法。如果这个Service之前还没有创建过,onCreate()方法会 先于onStartCommand()方法执行。

Service启动了之后会一直保持运行状态,直到 stopService()或stopSelf()方法被调用,或者被系统回收。注意,虽然每调用一次 startService()方法,onStartCommand()就会执行一次,但实际上每个Service只会存在 一个实例。所以不管你调用了多少次startService()方法,只需调用一次stopService() 或stopSelf()方法,Service就会停止。

另外,还可以调用Context的bindService()来获取一个Service的持久连接,这时就会回调 Service中的onBind()方法。类似地,如果这个Service之前还没有创建过,onCreate()方 法会先于onBind()方法执行。之后,调用方可以获取到onBind()方法里返回的IBinder对象 的实例,这样就能自由地和Service进行通信了。只要调用方和Service之间的连接没有断开, Service就会一直保持运行状态,直到被系统回收。

当调用了startService()方法后,再去调用stopService()方法。这时Service中的 onDestroy()方法就会执行,表示Service已经销毁了。类似地,当调用了bindService() 方法后,再去调用unbindService()方法,onDestroy()方法也会执行,这两种情况都很好 理解。但是需要注意,我们是完全有可能对一个Service既调用了startService()方法,又调用了bindService()方法的, 在这种情况下该如何让Service销毁呢?根据Android系统的机制,一个Service只要被 启动或者被绑定了之后,就会处于运行状态,必须要让以上两种条件 同时不满足,Service才能被销毁。所以,这种情况下要同时调用stopService()和 unbindService()方法,onDestroy()方法才会执行。

Service的更多技巧

使用前台Service

如果你希望 Service能够一直保持运行状态,就可以考虑使用前台Service。前台Service和普通Service最 大的区别就在于,它一直会有一个正在运行的图标在系统的状态栏显示,下拉状态栏后可以看 到更加详细的信息,非常类似于通知的效果

修改MyService中的代码 这部分代码和创建notification很像,最后调用startForeground

override fun onCreate() {
        super.onCreate()
        Log.d("MyService", "onCreate executed")

        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel("my_service", "我的服务", NotificationManager.IMPORTANCE_DEFAULT)
            manager.createNotificationChannel(channel)
        }

        val intent = Intent(this, MainActivity::class.java)
	// 此处与原书不同,需要判断版本
        val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
        } else {
            PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_ONE_SHOT)
        }
//        val pi = PendingIntent.getActivity(this, 0, intent, 0)
        val notification = NotificationCompat.Builder(this, "my_service")
            .setContentTitle("This is content title")
            .setContentText("This is content text")
            .setSmallIcon(R.drawable.small_icon)
            .setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.large_icon))
            .setContentIntent(pi)
            .build()
        startForeground(1, notification)
    }

调用startForeground()方法后就会让MyService变成一个前 台Service,并在系统状态栏显示出来

另外,从Android 9.0系统开始,使用前台Service必须在AndroidManifest.xml文件中进行权 限声明才行

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.servicetest">

    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>

    <application
    ...

启动程序之后sdk版本太高,原书代码会报错

java.lang.IllegalArgumentException: com.example.imdemo: Targeting S+ (version 31 and above) requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified when creating a PendingIntent.

使用IntentService

Service中的代码都是默认运行在主线程当中的,如果直接在Service里处理一些耗时的逻辑,就很容易出现ANR(Application Not Responding)

所以这个时候就需要用到Android多线程编程的技术了,我们应该在Service的每个具体的方法 里开启一个子线程,然后在这里处理那些耗时的逻辑

标准的Service写法

class MyService : Service() {
    ...
    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
        thread {
		// 处理具体的逻辑
        }
        return super.onStartCommand(intent, flags, startId)
    }
}

实现让一个Service在执行完毕之后自动停止功能

class MyService : Service() {
    ...
    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
        thread {
// 处理具体的逻辑
            stopSelf()
        }
        return super.onStartCommand(intent, flags, startId)
    }
}

Service一旦启动就会一直处于运行状态,必须调用stopService()或 stopSelf()方法,或者被系统回收,所以在执行完任务之后可以调用stopSelf()结束Service

为了可以简单地创建一个异步的、会自动停止的Service,Android专门提供了一个 IntentService类注意:IntentService已经被弃用,这个类就很好地解决了前面所提到的两种尴尬,下面我们就来看一下它的用 法。

创建一个MyIntentService继承自IntentService

class MyIntentService: IntentService("MyIntentService") {

    override fun onHandleIntent(intent: Intent?) {
	// 打印当前线程的id
        Log.d("MyIntentService", "Thread id is ${Thread.currentThread().name}")
    }
    override fun onDestroy() {
        super.onDestroy()
        Log.d("MyIntentService", "onDestroy executed")
    }

}

这里首先要求必须先调用父类的构造函数,并传入一个字符串,这个字符串可以随意指定,只 在调试的时候有用。然后要在子类中实现onHandleIntent()这个抽象方法,这个方法中可以 处理一些耗时的逻辑,而不用担心ANR的问题,因为这个方法(onHandleIntent)已经是在子线程中运行的了。

在activity_main.xml文件中添加按钮 修改MainActivity中代码 并且在AndroidManifest.xml文件中注册service

bind.startIntentServiceBtn.setOnClickListener {
            // 打印主线程id
            Log.d("MainActivity", "Threa id is ${Thread.currentThread().name}")
            val intent = Intent(this, MyIntentService::class.java)
            startService(intent)
        }

Kotlin 泛型的高级特性

对泛型进行实化

Java的泛型擦出机制

在JDK 1.5之前,Java是没有泛型功能的,那个时候诸如List之类的数据结构可以存储任意类型 的数据,取出数据的时候也需要手动向下转型才行,这不仅麻烦,而且很危险Java的泛 型功能是通过类型擦除机制来实现的. 泛型对于类型的约束只在编译时期存在,运行的时候仍然会按照JDK 1.5之前的机制来运行,JVM是识别不出来我们在代码中指定的泛型类型的

所有基于JVM的语言,它们的泛型功能都是通过类型擦除机制来实现的,其中当然也包括了Kotlin

这种机制使得我们不可能使用a is T或者T::class.java这样的语法,因为T的实际 类型在运行的时候已经被擦除了。

然而不同的是,Kotlin提供了一个内联函数的概念, 内联函数中的代码会在编译的时候自动被替换到调用它的地方,这样的话也就不存

在什么泛型擦除的问题了,因为代码在编译之后会直接使用实际的类型来替代内联函数中的泛型声明,

这就意味着Kotlin是可以将内联函数中的泛型实化的

用法: 首先,该函数必须是内联函数才行,也就是要用 inline 键字来修饰该函数。 其次,在声明泛型的地方必须加上 reified 关键字来表示该泛型要进行实化

示例代码

inline fun <reified T> getGenericType() = T::class.java

上述函数中的泛型T就是一个被实化的泛型,因为它满足了内联函数reified关键字这两个前提条件

getGenericType()函数直接返回了当前指定泛型的实际类型。T.class这样的语法在Java 中是不合法的,而在Kotlin中,借助泛型实化功能就可以使用T::class.java这样的语法了。

使用该函数

inline fun <reified T> getGenericType() = T::class.java

fun main() {
    val result1 = getGenericType<String>()
    val result2 = getGenericType<Int>()

    println("result1 is $result1")
    println("result2 is $result2")
}

// 运行结果
result1 is class java.lang.String
result2 is class java.lang.Integer

泛型实化的应用

启动一个Activity可以这么写

val intent = Intent(context, TestActivity::class.java)
context.startActivity(intent)

使用泛型实化简化代码

inline fun <reified T> startActivity(context: Context) {
    val intent = Intent(context, T::class.java)
    context.startActivity(intent)
}

在对应Intent中传入参数

inline fun <reified T> startActivity(context: Context, block: Intent.() -> Unit) {
    val intent = Intent(context, T::class.java)
    intent.block()
    context.startActivity(intent)
}

调用时可以如下写

startActivity<TestActivity>(context) {
	putExtra("param1", "data1")
	putExtra("param2", 123)
}

泛型的协变

在开始学习协变和逆变之前,我们还得先了解一个约定 一个泛型类或者泛型接口中的方法, 它的参数列表接收数据的地方,因此可以称它为in位置,而它的返回值输出数据的地方,因此可以称它为out位置

首先定义三个类

open class Person(val name: String, val age: Int)

class Student(name: String, age: Int): Person(name, age)

class Teacher(name: String, age: Int): Person(name, age)

如果某个方法接收一个Person类型的参数,而我们传入一个Student 的实例,这样合不合法呢?很显然,因为Student是Person的子类,学生也是人呀,因此这是 一定合法的。

如果某个方法接收一个List类型的参数,而我们传 入一个List的实例,这样合不合法呢?看上去好像也挺正确的,但是Java中是不 允许这么做的,因为List不能成为List的子类,否则将可能存在类型 转换的安全隐患。

举例说明

class SimpleData<T> {
    private var data: T? = null
    fun set(t: T?) {
        data = t
    }
    fun get(): T? {
        return data
    }
}

下面是一个错误的调用

fun main() {
    val student = Student("Tom", 19)
    val data = SimpleData<Student>()
    data.set(student)
    handleSimpleData(data) // 实际上这行代码会报错,这里假设它能编译通过
    val studentData = data.get()
}
fun handleSimpleData(data: SimpleData<Person>) {
    val teacher = Teacher("Jack", 35)
    data.set(teacher)
}

我们创建了一个Student的实例,并将它封装 到 SimpleData<Student> 当中,然后将SimpleData<Student> 作为参数传递给 handleSimpleData()方法。但是handleSimpleData()方法接收的是一个 SimpleData<Person> 参数(这里假设可以编译通过),那么在handleSimpleData()方法 中,我们就可以创建一个Teacher的实例,并用它来替换 SimpleData<Person> 参数中的原 有数据。这种操作肯定是合法的,因为Teacher也是Person的子类,所以可以很安全地将 Teacher的实例设置进去。

但是问题马上来了,回到main()方法当中,我们调用 SimpleData<Student> 的get()方法 来获取它内部封装的Student数据,可现在 SimpleData<Student> 中实际包含的却是一个 Teacher的实例,那么此时必然会产生类型转换异常。

在Java中可以将泛型使用 <? extends T> 来支持这种写法,但是,泛型参数只能是读不能写,这也就是泛型的协变

Kotlin中泛型的协变:

假如定义了一个 MyClass<T> 的泛型类,其中A 是B的子类型,同时 MyClass<A> 又是 MyClass<B> 的子类,那么我们就可以称MyClass在T这个泛型上是协变的

如果一个泛型类 在其泛型类型的数据上是只读的话,那么它是没有类型转换安全隐患的。而要实现这一点,则 需要让MyClass类中的所有方法都不能接收T类型的参数。换句话说,T只能出现在out位置 上,而不能出现在in位置上。

修改SimpleData的代码

class SimpleData<out T> {
    private var data: T? = null
    fun get(): T? {
        return data
    }
}

即out关键字用于那些用于生产的类

由于泛型T不能出现在in位置上,因此我们也就不能使用set()方法为data参数赋值了,所以这 里改成了使用构造函数的方式来赋值。你可能会说,构造函数中的泛型T不也是在in位置上的 吗?没错,但是由于这里我们使用了val关键字,所以构造函数中的泛型T仍然是只读的,因此 这样写是合法且安全的。另外,即使我们使用了var关键字,但只要给它加上private修饰 符,保证这个泛型T对于外部而言是不可修改的,那么就都是合法的写法。

泛型的逆变

Kotlin中泛型的逆变: 假如定义了一个MyClass<T> 泛型类,其中A是B的子类型,同时MyClass<B> 又是MyClass<A> 的子类型,那么我们就可以称MyClass在T这个泛型上是逆变的。

协变和逆变的区别

看例子

定义Transformer接口

interface Transformer<T>{
    fun transform(t: T): String
}

对接口进行实现

interface Transformer<T> {
    fun transform(t: T): String
}

fun main() {
    val trans = object : Transformer<Person> {
        override fun transform(t: Person): String {
            return "${t.name} ${t.age}"
        }
    }
    handleTransformer(trans) // 这行代码会报错
}

fun handleTransformer(trans: Transformer<Student>) {
    val student = Student("Tom", 19)
    val result = trans.transform(student)
}

这段代码从安全的角度来分析是没有任何问题的,因为Student是Person的子类,使用 Transformer的匿名类实现将Student对象转换成一个字符串也是绝对安全的,并 不存在类型转换的安全隐患。但是实际上,在调用handleTransformer()方法的时候却会提 示语法错误,原因也很简单,Transformer并不是Transformer的子 类型。

使用逆变

interface Transformer<in T> {
    fun transform(t: T): String
}

即in关键字用于那些用于消费的类

此处对应Java中的 <? super T> PECS原则(Producer Extends Consumer Super)

最后我们再来介绍一下逆变功能在Kotlin内置API中的应用,比较典型的例子就是Comparable 的使用。Comparable是一个用于比较两个对象大小的接口

部分源码

public interface Comparable<in T> {
    /**
     * Compares this object with the specified object for order. Returns zero if this object is equal
     * to the specified [other] object, a negative number if it's less than [other], or a positive number
     * if it's greater than [other].
     */
    public operator fun compareTo(other: T): Int
}

想象如下场景,如果我们使用 Comparable实现了让两个Person对象比较大小的逻辑,那么用这段逻辑去比较两 个Student对象的大小也一定是成立的,因此让Comparable成为 Comparable的子类合情合理,这也是逆变非常典型的应用。

使用网络技术

WebView的用法

新建WebViewTest项目 修改activity_main.xml文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <WebView
        android:id="@+id/webView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

修改MainActivity中的代码

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.webkit.WebViewClient
import com.example.webviewtest.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var bind: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)

        bind.webView.settings.javaScriptEnabled = true
        bind.webView.webViewClient = WebViewClient()
        bind.webView.loadUrl("https://www.baidu.com")
    }
}

我们调用了WebView的setWebViewClient()方法,并传入 了一个WebViewClient的实例。这段代码的作用是,当需要从一个网页跳转到另一个网页时, 我们希望目标网页仍然在当前WebView中显示,而不是打开系统浏览器

另外还需要注意,由于本程序使用到了网络功能,而访问网络是需要声明权限的,因此我们还 得修改AndroidManifest.xml文件

<uses-permission android:name="android.permission.INTERNET" />

使用HTTP访问网络

使用HttpURLConnection

在过去,Android上发送HTTP请求一般有两种方式:HttpURLConnection和HttpClient。不 过由于HttpClient存在API数量过多、扩展困难等缺点,Android团队越来越不建议我们使用这 种方式。终于在Android 6.0系统中,HttpClient的功能被完全移除了,标志着此功能被正式弃 用,因此本小节我们就学习一下现在官方建议使用的HttpURLConnection的用法。

首先需要获取HttpURLConnection的实例,一般只需创建一个URL对象,并传入目标的网络地 址,然后调用一下openConnection()方法即可

val url = URL("https://www.baidu.com")
val connection = url.openConnection() as HttpURLConnection

设置HTTP请求方法

connection.requestMethod = "GET"

接下来就可以进行一些自由的定制了,比如设置连接超时、读取超时的毫秒数,以及服务器希 望得到的一些消息头等。这部分内容根据自己的实际情况进行编写

connection.connectTimeout = 8000
connection.readTimeout = 8000

之后再调用getInputStream()方法就可以获取到服务器返回的输入流了,剩下的任务就是对 输入流进行读取

val input = connection.inputStream

最后可以调用disconnect()方法将这个HTTP连接关闭

connection.disconnect()

新建NetWorkTest项目 修改activity_main.xml文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/sendRequestBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Send Request" />
    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent" >
        <TextView
            android:id="@+id/responseText"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    </ScrollView>

</LinearLayout>

修改MainActivity代码,点击按钮发送Http请求

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.networktest.databinding.ActivityMainBinding
import java.io.BufferedReader
import java.io.InputStreamReader
import java.lang.Exception
import java.lang.StringBuilder
import java.net.HttpURLConnection
import java.net.URL
import kotlin.concurrent.thread

class MainActivity : AppCompatActivity() {

    private lateinit var bind: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)

        bind.sendRequestBtn.setOnClickListener {
            sendRequestWithHttpURLConnection()
        }

    }
    private fun sendRequestWithHttpURLConnection() {
        // 开启线程发起网络请求
        thread {
            var connection: HttpURLConnection? = null
            try {
                val response = StringBuilder()
                val url = URL("https://www.baidu.com")
                connection = url.openConnection() as HttpURLConnection
                connection.connectTimeout = 8000
                connection.readTimeout = 8000
                val input = connection.inputStream
                // 对获取的输入流进行读取
                val reader = BufferedReader(InputStreamReader(input))
                reader.use {
                    reader.forEachLine {
                        response.append(it)
                    }
                }
                showResponse(response.toString())
            } catch (e: Exception) {
                e.printStackTrace()
            } finally {
                connection?.disconnect()
            }

        }
    }

    private fun showResponse(response: String) {
        runOnUiThread {
            // 在这里进行UI操作,将结果显示在界面上
            bind.responseText.text = response
        }
    }
}

在showResponse()方法里,则是调用了一个runOnUiThread()方法,然后在这 个方法的Lambda表达式中进行操作,将返回的数据显示到界面上

那么这里为什么要用这个runOnUiThread()方法呢?别忘了,Android是不允许在子线程中进行UI操作的。

在AndroidManifest.xml中声明网络权限


<uses-permission android:name="android.permission.INTERNET" />

此处默认是GET请求,需要POST或者其他请求类型,使用

connection.requestMethod = "POST" // 以POST请求为例

如果为POST,需要发送数据

val output = DataOutputStream(connection.outputStream)
output.writeBytes("username=admin&password=123456")

使用OkHttp

在app/build.gradle中添加依赖

dependencies {

    implementation 'com.squareup.okhttp3:okhttp:4.10.0'
    //...
}

创建OkHttpClient实例

val client = OkHttpClient()

构建Request对象

val request = Request.Builder().build()

当然,上述代码只是创建了一个空的Request对象,并没有什么实际作用,我们可以在最终的 build()方法之前连缀很多其他方法来丰富这个Request对象。比如可以通过url()方法来设 置目标的网络地址

val request = Request.Builder()
        .url("https://www.baidu.com")
        .build()

之后调用OkHttpClient的newCall()方法来创建一个Call对象,并调用它的execute()方法 来发送请求并获取服务器返回的数据

val response = client.newCall(request).execute()

Response对象就是服务器返回的数据了,我们可以使用如下写法来得到返回的具体内容

val responseData = response.body?.string()

如果是发起一条POST请求,会比GET请求稍微复杂一点,我们需要先构建一个Request Body 对象来存放待提交的参数

此处为构建表单参数

val requestBody = FormBody.Builder()
        .add("username", "admin")
        .add("password", "123456")
        .build()

构建请求 之后使用execute执行请求

val request = Request.Builder()
	.url("https://www.baidu.com")
	.post(requestBody)
	.build()

使用OkHttp修改NetWorkTest项目

private fun sendRequestWithHttpURLConnection() {
        // 开启线程发起网络请求
        thread {
            try {
                // 首先获取一个Client
                val client = OkHttpClient()
                // 构建请求
                val request = Request.Builder()
                    .url("https://www.baidu.com")
                    .build()
                // 执行
                val resp = client.newCall(request).execute()
                // 获取body数据
                val data = resp.body?.string()
                if (data != null) {
                    showResponse(data)
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }

        }
}

解析XML格式数据

获取xml数据 此处因为我是Ubuntu20.04操作系统,所以直接使用docker启动Apache服务器

# docker拉取镜像
sudo docker pull ubuntu/apache2:2.4-20.04_beta
# docker运行容器
sudo docker run -d --name for-android-apache -e TZ=UTC -v /path/where/you/want:/var/www/html -p 8080:80 ubuntu/apache2:2.4-20.04_beta

之后访问本地8080端口即可

在本地挂载目录内添加xml文件

<apps>
        <app>
                <id>1</id>
                <name>Google Maps</name>
                <version>1.0</version>
        </app>
        <app>
                <id>2</id>
                <name>Chrome</name>
                <version>2.1</version>
        </app>
        <app>
                <id>3</id>
                <name>Google Play</name>
                <version>2.3</version>
        </app>
<apps>

Pull解析方式

解析XML格式的数据其实也有挺多种方式的,本节中我们学习比较常用的两种:Pull解析和SAX解析

在MainActivity中添加解析xml文件方法

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import com.example.networktest.databinding.ActivityMainBinding
import okhttp3.OkHttpClient
import okhttp3.Request
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.StringReader
import java.lang.Exception
import kotlin.concurrent.thread

class MainActivity : AppCompatActivity() {

    private lateinit var bind: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)

        bind.sendRequestBtn.setOnClickListener {
            sendRequestWithHttpURLConnection()
        }

    }

    private fun sendRequestWithHttpURLConnection() {
        // 开启线程发起网络请求
        thread {
            try {
                // 首先获取一个Client
                val client = OkHttpClient()
                // 构建请求
                val request = Request.Builder()
		     // 注意此处ip为本机实际ip,在虚拟机内使用localhost
		     // 无法发送网络请求
                    .url("http://ip:8080/get_data.xml")
                    .build()
                // 执行
                val resp = client.newCall(request).execute()
                // 获取body数据
                val data = resp.body?.string()
                if (data != null) {
//                    showResponse(data)
                    parseXMLWithPUll(data)
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }

        }
    }

    private fun showResponse(response: String) {
        runOnUiThread {
            // 在这里进行UI操作,将结果显示在界面上
            bind.responseText.text = response
        }
    }

    private fun parseXMLWithPUll(xmlData: String) {
        try {
            val factory = XmlPullParserFactory.newInstance()
            val xmlPullParser = factory.newPullParser()
            xmlPullParser.setInput(StringReader(xmlData))
            var eventType = xmlPullParser.eventType
            var id = ""
            var name = ""
            var version = ""
            while (eventType != XmlPullParser.END_DOCUMENT) {
                val nodeName = xmlPullParser.name
                when (eventType) {
                   // 开始解析某个节点
                    XmlPullParser.START_TAG -> {
                        when (nodeName) {
                            "id" -> id = xmlPullParser.nextText()
                            "name" -> name = xmlPullParser.nextText()
                            "version" -> version = xmlPullParser.nextText()
                        }
                    }
                    // 完成解析某个节点
                    XmlPullParser.END_TAG -> {
                        if ("app" == nodeName) {
                            Log.d("MainActivity", "id is $id")
                            Log.d("MainActivity", "name is $name")
                            Log.d("MainActivity", "version is $version")
                        }
                    }
                }
                eventType = xmlPullParser.next()
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

好了,整体的过程就是这么简单,不过在程序运行之前还得再进行一项额外的配置。从Android 9.0系统开始,应用程序默认只允许使用HTTPS类型的网络请求,HTTP类型的网络请求因为有 安全隐患默认不再被支持,而我们搭建的Apache服务器现在使用的就是HTTP

那么为了能让程序使用HTTP,我们还要进行如下配置才可以。右击res目录 →New→Directory,创建一个xml目录,接着右击xml目录→New→File,创建一个 network_config.xml文件。

内容

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
</network-security-config>

之后在AndroidManifest.xml文件中启用配置文件

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:networkSecurityConfig="@xml/network_config">
...
</application>

SAX解析方式

Pull解析方式虽然非常好用,但它并不是我们唯一的选择。SAX解析也是一种特别常用的XML解 析方式,虽然它的用法比Pull解析要复杂一些,但在语义方面会更加清楚

要使用SAX解析,通常情况下我们会新建一个类继承自DefaultHandler,并重写父类的5个方法

class ContentHandler: DefaultHandler() {
    override fun startDocument() {
        super.startDocument()
    }

    override fun startElement(
        uri: String?,
        localName: String?,
        qName: String?,
        attributes: Attributes?
    ) {
        super.startElement(uri, localName, qName, attributes)
    }

    override fun characters(ch: CharArray?, start: Int, length: Int) {
        super.characters(ch, start, length)
    }

    override fun endElement(uri: String?, localName: String?, qName: String?) {
        super.endElement(uri, localName, qName)
    }

    override fun endDocument() {
        super.endDocument()
    }

}

具体实现

import android.util.Log
import org.xml.sax.Attributes
import org.xml.sax.helpers.DefaultHandler
import kotlin.text.StringBuilder

class ContentHandler: DefaultHandler() {

    private var nodeName = ""
    private lateinit var id: StringBuilder
    private lateinit var name: StringBuilder
    private lateinit var version: StringBuilder

    override fun startDocument() {
        id = StringBuilder()
        name = StringBuilder()
        version = StringBuilder()
    }

    override fun startElement(
        uri: String?,
        localName: String?,
        qName: String?,
        attributes: Attributes?
    ) {
        // 记录当前节点名
        nodeName = localName!!
        Log.d("ContentHandler", "uri is $uri")
        Log.d("ContentHandler", "localName is $localName")
        Log.d("ContentHandler", "qName is $qName")
        Log.d("ContentHandler", "attributes is $attributes")
    }

    override fun characters(ch: CharArray?, start: Int, length: Int) {
        // 根据当前节点名判断将内容添加到哪一个StringBuilder对象中
        when (nodeName) {
            "id" -> id.append(ch, start, length)
            "name" -> name.append(ch, start, length)
            "version" -> version.append(ch, start, length)
        }
    }

    override fun endElement(uri: String?, localName: String?, qName: String?) {
        if ("app" == localName) {
            Log.d("ContentHandler", "id is ${id.toString().trim()}")
            Log.d("ContentHandler", "name is ${name.toString().trim()}")
            Log.d("ContentHandler", "version is ${version.toString().trim()}")
        }
        id.setLength(0)
        name.setLength(0)
        version.setLength(0)
    }

    override fun endDocument() {
    }

}

修改MainActivity中的代码

private fun parseXMLWithSAX(xmlData: String) {
        try {
            val factory = SAXParserFactory.newInstance()
            val xmlReader = factory.newSAXParser().xmlReader
            val handler = ContentHandler()
            // 将ContentHandler的实例设置到XMLReader中
            xmlReader.contentHandler = handler
            // 开始执行解析
            xmlReader.parse(InputSource(StringReader(xmlData)))
        }catch (e: Exception) {
            e.printStackTrace()
        }
    }

解析JSON格式数据

使用JSONObject

类似地,解析JSON数据也有很多种方法,可以使用官方提供的JSONObject,也可以使用 Google的开源库GSON。另外,一些第三方的开源库如Jackson、FastJSON等也非常不错 需该MainActivity中的代码

private fun parseJSONWithJSONObject(jsonData: String) {
        try {
            val jsonArray = JSONArray(jsonData)
            for (i in 0 until jsonArray.length()) {
                val jsonObject = jsonArray.getJSONObject(i)
                val id = jsonObject.getString("id")
                val name = jsonObject.getString("name")
                val version = jsonObject.getString("version")
                Log.d("MainActivity", "id is $id")
                Log.d("MainActivity", "name is $name")
                Log.d("MainActivity", "version is $version")
            }
        }catch (e: Exception) {
            e.printStackTrace()
        }
    }

使用GSON

如果你认为使用JSONObject来解析JSON数据已经非常简单了,那你就太容易满足了。Google 提供的GSON开源库可以让解析JSON数据的工作简单到让你不敢想象的地步,那我们肯定是不 能错过这个学习机会的

在app/build.gradle的dependencies中添加依赖

implementation 'com.google.code.gson:gson:2.8.6'

GSON的强大之处在于可以将一段JSON格式的字符串自动映射成一个对象,从而不需要我们再手动编写代码进行解析了 比如说一段JSON格式的数据如下

{"name": "Tom", "age": 20}

那我们就可以定义一个Person类,并加入name和age这两个字段,然后只需简单地调用如下代 码就可以将JSON数据自动解析成一个Person对象了

val gson = Gson()
val person = gson.fromJson(jsonData, Person::class.java)

如果需要解析的是一段JSON数组,会稍微麻烦一点,比如如下格式的数据

[{"name":"Tom","age":20}, {"name":"Jack","age":25}, {"name":"Lily","age":22}]

这个时候,我们需要借助TypeToken将期望解析成的数据类型传入fromJson()方法中

val typeOf = object: TypeToken<List<Person>>() {}.type
val people = gson.fromJson<List<Person>>(jsonData, typeOf)

新增一个App类

class App(val id: String, val name: String, val version: String)

修改MainActivity中的代码

private fun parseJSONWithGSON(jsonData: String) {
        val gson = Gson()
        val typeOf = object: TypeToken<List<App>>(){}.type
        val apps = gson.fromJson<List<App>>(jsonData, typeOf)
        apps.forEach {
            Log.d("MainActivity", "id is ${it.id}")
            Log.d("MainActivity", "name is ${it.name}")
            Log.d("MainActivity", "version is ${it.version}")
        }
    }

网络请求回调的实现方式

在获取到服务器响应的数据后,我们就可以对它进行解析和处理了。但是需要注意,网络请求 通常属于耗时操作,而sendHttpRequest()方法的内部并没有开启线程,这样就有可能导致 在调用sendHttpRequest()方法的时候主线程被阻塞。 你可能会说,很简单嘛,在sendHttpRequest()方法内部开启一个线程,不就解决这个问题 了吗?其实没有你想象中那么容易,因为如果我们在sendHttpRequest()方法中开启一个线 程来发起HTTP请求,服务器响应的数据是无法进行返回的。这是由于所有的耗时逻辑都是在子 线程里进行的,sendHttpRequest()方法会在服务器还没来得及响应的时候就执行结束了, 当然也就无法返回响应的数据了。

使用回调 定义接口

interface HttpCallbackListener {
    fun onFinish(response: String)
    fun onError(e: Exception)
}

实现HttpUtil,并在其中使用回调

object HttpUtil {
    fun sendHttpRequest(address: String, listener: HttpCallbackListener) {
        thread {
            var connection: HttpURLConnection? = null
            try{
                val response = StringBuilder()
                val url = URL(address)
                connection = url.openConnection() as HttpURLConnection
                connection.connectTimeout = 8000
                connection.readTimeout = 8000
                val input = connection.inputStream
                val reader = BufferedReader(InputStreamReader(input))
                reader.use {
                    reader.forEachLine {
                        response.append(it)
                    }
                }
                // 回调onFinish方法
                listener.onFinish(response.toString())
            }catch (e: Exception) {
                e.printStackTrace()
                // 回调onError方法
                listener.onError(e)
            } finally {
                connection?.disconnect()
            }
        }
    }
}

现在sendHttpRequest()方法接收两个参数,因此我们在调用它的时候还需要将 HttpCallbackListener的实例传入

HttpUtil.sendHttpRequest(address, object : HttpCallbackListener {
    override fun onFinish(response: String) {
// 得到服务器返回的具体内容
    }
    override fun onError(e: Exception) {
// 在这里对异常情况进行处理
    }
})

使用OkHttp的回调

fun sendOkHttpRequest(address: String, callback: okhttp3.Callback) {
    val client = OkHttpClient()
    val request = Request.Builder()
        .url(address)
        .build()
    client.newCall(request).enqueue(callback)
}

可以看到,sendOkHttpRequest()方法中有一个okhttp3.Callback参数,这个是OkHttp 库中自带的回调接口,类似于我们刚才自己编写的HttpCallbackListener。然后在 client.newCall()之后没有像之前那样一直调用execute()方法,而是调用了一个 enqueue()方法,并把okhttp3.Callback参数传入

OkHttp在 enqueue()方法的内部已经帮我们开好子线程了,然后会在子线程中执行HTTP请求,并将最 终的请求结果回调到okhttp3.Callback当中

另外,需要注意的是,不管是使用HttpURLConnection还是OkHttp,最终的回调接口都还是 在子线程中运行的,因此我们不可以在这里执行任何的UI操作,除非借助runOnUiThread()方法来进行线程转换

最好用的网络库: Retrofit

Retrofit同样是一款由Square公司开发的网络库,但是它和OkHttp的定位完全不同。OkHttp 侧重的是底层通信的实现,而Retrofit侧重的是上层接口的封装

事实上,Retrofit就是Square 公司在OkHttp的基础上进一步开发出来的应用层网络通信库,使得我们可以用更加面向对象的思维进行网络操作

Retrofit的基本用法

在app/build.gradle的dependencies闭包下添加

提示:最新版去google mavenrepo 在maven仓库下找最新版
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

由于Retrofit是基于OkHttp开发的,因此添加上述第一条依赖会自动将Retrofit、OkHttp和 Okio这几个库一起下载,我们无须再手动引入OkHttp库 因此上述第二条依赖就是一个Retrofit的转换库,它是借助GSON

来解析JSON数据的,所以会自动将GSON库一起下载下来,这样我们也不用手动引入GSON库 了。除了GSON之外,Retrofit还支持各种其他主流的JSON解析库,包括Jackson、Moshi等, 不过毫无疑问GSON是最常用的。

接下来,我们可以根据服务器接口的功能进行归类,创建不同种类的接口文件,并在其中定义 对应具体服务器接口的方法。不过由于我们的Apache服务器上其实只有一个获取JSON数据的 接口,因此这里只需要定义一个接口文件,并包含一个方法即可

import retrofit2.Call
import retrofit2.http.GET

interface AppService {
    @GET("get_data.json")
    fun getAppData(): Call<List<App>>
}

修改activity_main.xml文件,添加一个按钮 修改MainActivity中的代码

bind.getAppDataBtn.setOnClickListener {
            val retrofit = Retrofit.Builder()
                .baseUrl("http://115.152.113.20:8080/")
                .addConverterFactory(GsonConverterFactory.create())
                .build()
            val appServer = retrofit.create(AppService::class.java)
            appServer.getAppData().enqueue(object: Callback<List<App>>{
                override fun onResponse(call: Call<List<App>>, response: Response<List<App>>) {
                    response.body()?.forEach {
                        Log.d("MainActivity", "id is ${it.id}")
                        Log.d("MainActivity", "name is ${it.name}")
                        Log.d("MainActivity", "version is ${it.version}")
                    }
                }

                override fun onFailure(call: Call<List<App>>, t: Throwable) {
                    t.printStackTrace()
                }

            })
        }

不过由于这里使用的服务器接口仍然是HTTP,因此我们还要配置网络

处理复杂的接口地址类型

那么本小节我们就来学习一下如何使用Retrofit来应对这些千变万化的情况。

先定义一个Data类

class Data(val id: String, val content: String)

最简单的一种情况,接口地址是静态的,永远不会改变。那么对应到Retrofit当中,使用如 下的写法即可:

interface ExampleService {
	@GET("get_data.json")
	fun getData(): Call<Data>
}

部分变化的地址

GET http://example.com/<page>/get_data.json

在这个接口当中,部分代表页数,我们传入不同的页数,服务器返回的数据也会不同。 这种接口地址对应到Retrofit当中应该怎么写呢?其实也很简单

interface ExampleService {
	@GET("{page}/get_data.json")
	fun getData(@Path("page") page: Int): Call<Data>
}

传入一系列参数

GET http://example.com/get_data.json?u=<user>&t=<token>

Retrofit中对应的写法

interface ExampleService {
	@GET("get_data.json")
	fun getData(@Query("u") user: String, @Query("t") token: String): Call<Data>
}

而Retrofit对所有常用的HTTP请求类型都进行了支持,使用@GET、@POST、@PUT、@PATCH、 @DELETE注解,就可以让Retrofit发出相应类型的请求了

假如服务器提供了如下接口

DELETE http://example.com/data/<id>

Retrofit写法

interface ExampleService {
	@DELETE("data/{id}")
	fun deleteData(@Path("id") id: String): Call<ResponseBody>
}

此处 由于POST、PUT 、PATCH、DELETE这几种请求类型与GET请求不同,它们更多是用于操作服 务器上的数据,而不是获取服务器上的数据,所以通常它们对于服务器响应的数据并不关心。 这个时候就可以使用ResponseBody,表示Retrofit能够接收任意类型的响应数据,并且不会对响应数据进行解析

向服务器提交数据 使用@Body注解

interface ExampleService {
	@POST("data/create")
	fun createData(@Body data: Data): Call<ResponseBody>
}

这样当Retrofit发出POST请求时,就会自动将Data对象中的数据转换成JSON格 式的文本,并放到HTTP请求的body部分,服务器在收到请求之后只需要从body中将这部分数 据解析出来即可。这种写法同样也可以用来给PUT、PATCH、DELETE类型的请求提交数据。

在header中添加数据

假如有以下Header参数

User-Agent: okhttp
Cache-Control: max-age=0

Retrofit写法

interface ExampleService {
	@Headers("User-Agent: okhttp", "Cache-Control: max-age=0")
	@GET("get_data.json")
	fun getData(): Call<Data>
}

动态指定Header

interface ExampleService {
	@GET("get_data.json")
	fun getData(@Header("User-Agent") userAgent: String,
	@Header("Cache-Control") cacheControl: String): Call<Data>
}

Retrofit构建器的最佳写法

简化Builder, create service的过程 因为构建出的Retrofit对象是全局通用的,只需要在调用create()方法时针对不同 的Service接口传入相应的Class类型即可

新建ServiceCreator类

object ServiceCreator {
    private const val BASE_URL = "http://115.152.113.20:8080/"

    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    fun <T> create(serviceClass: Class<T>): T = retrofit.create(serviceClass)
}

不过上述代码其实仍然还有优化空间,还记得我们在上一章的Kotlin课堂中学习的泛型实化功能 吗?这里立马就可以应用起来了

object ServiceCreator {
    private const val BASE_URL = "http://115.152.113.20:8080/"

    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    fun <T> create(serviceClass: Class<T>): T = retrofit.create(serviceClass)

    inline fun <reified T> create(): T = create(T::class.java)
}

则在调用时可以如下调用

val appServer = ServiceCreator.create<AppService>()

Kotlin 使用协程编写更高效的并发程序

协程允许我们在单线程模式下模拟多线程编程的效果,代码执行时的挂起与恢复完 全是由编程语言来控制的,和操作系统无关。

协程的基本用法

在app/build.gradle中添加依赖

runtimeOnly 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3'

如何开启一个协程?最简单的方式就是使用 Global.launch函数

import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch

fun main(args: Array<String>) {
    GlobalScope.launch {
        println("code run in coroutine scope")
    }
}

fun main(args: Array<String>) {
    GlobalScope.launch {
        println("code run in coroutine scope")
        delay(1500)
        println("code run in coroutine scope finished")
    }
    Thread.sleep(1000)
}
// 此处线程sleep一千毫秒之后,不会等待协程,直接退出

我们在代码块中加入了一个delay()函数,并在之后又打印了一行日志。delay()函数可以让 当前协程延迟指定时间后再运行,但它和Thread.sleep()方法不同。**delay()函数是一个非

阻塞式的挂起函数,它只会挂起当前协程,并不会影响其他协程的运行**。而Thread.sleep() 方法会阻塞当前的线程,这样运行在该线程下的所有协程都会被阻塞。

让协程中的代码都运行完成再结束使用runBlocking函数

fun main(args: Array<String>) {
    runBlocking {
        println("code run in coroutine scope")
        delay(1500)
        println("code run in coroutine scope finished")
    }
    Thread.sleep(1000)
}

使用launch函数创建多个协程

fun main(args: Array<String>) {
    runBlocking {
        launch {
            println("launch1")
            delay(1000)
            println("launch1 finished")
        }

        launch {
            println("launch2")
            delay(1000)
            println("launch2 finished")
        }

    }
    Thread.sleep(1000)
}

注意这里的launch函数和我们刚才所使用的GlobalScope.launch函数不同。首先它必须在 协程的作用域中才能调用,其次它会在当前协程的作用域下创建子协程。子协程的特点是如果 外层作用域的协程结束了,该作用域下的所有子协程也会一同结束。相比而言, GlobalScope.launch函数创建的永远是顶层协程

val start = System.currentTimeMillis()
    runBlocking {
        repeat(100000) {
            launch {
                println(".")
            }
        }
    }
    val end = System.currentTimeMillis()
    println(end - start)
// 运行结果
502 //此处意思为502毫秒

这里使用repeat函数循环创建了10万个协程,不过在协程当中并没有进行什么有意义的操作, 只是象征性地打印了一个点,然后记录一下整个操作的运行耗时

不过,随着launch函数中的逻辑越来越复杂,可能你需要将部分代码提取到一个单独的函数 中。这个时候就产生了一个问题:我们在launch函数中编写的代码是拥有协程作用域的,但是 提取到一个单独的函数中就没有协程作用域了,那么我们该如何调用像delay()这样的挂起函数呢?

Kotlin提供了suspend关键字,使用它可以将任意函数声明成挂起函数,而挂起函数之间都是可以互相调用的(这个关键字只是声明,并没有其他作用)

suspend fun printDot() {
	println(".")
	delay(1000)
}

但是,suspend关键字只能将一个函数声明成挂起函数,是无法给它提供协程作用域的。比如 你现在尝试在printDot()函数中调用launch函数,一定是无法调用成功的,因为launch函 数要求必须在协程作用域当中才能调用

可以使用coroutineScope函数来解决: coroutineScope函数也是一个挂起函数, 因此可以在任何其他挂起函数中调用。它的特点是会继承外部的协程的作用域并创建一个子协程,借助这个特性,我们就可以给任意挂起函数提供协程作用域了

suspend fun printDot() = coroutineScope{
    launch {
        println(".")
        delay(1000)
    }
}

coroutineScope函数和runBlocking函数还有点类似,它可以保证其作用域内的所 有代码和子协程在全部执行完之前,外部的协程会一直被挂起

fun main() {
    runBlocking {
        coroutineScope {
            launch {
                for (i in 1..10) {
                    println(i)
                    delay(1000)
                }
            }
        }
        println("coroutineScope finished")
    }
    println("runBlocking finished")
}

coroutineScope函数只会阻塞当前协程,既不影响其他协程,也不影响任何线程,因此是不 会造成任何性能上的问题的。而runBlocking函数由于会挂起外部线程,如果你恰好又在主线 程中当中调用它的话,那么就有可能会导致界面卡死的情况,所以不太推荐在实际项目中使用。

更多的作用域构造器

在上一小节中,我们学习了GlobalScope.launch、runBlocking、launch、 coroutineScope这几种作用域构建器,它们都可以用于创建一个新的协程作用域。不过 GlobalScope.launch和runBlocking函数是可以在任意地方调用的,coroutineScope函 数可以在协程作用域或挂起函数中调用,而launch函数只能在协程作用域中调用。

那么协程要怎样取消呢?不管是GlobalScope.launch函数还是launch函数,它们都会返回 一个Job对象,只需要调用Job对象的cancel()方法就可以取消协程了

val job = GlobalScope.launch {
        // 处理具体逻辑
    }
    job.cancel()

实际项目中比较常用的写法

val job = Job()
val scope = CoroutineScope(job)
scope.launch {
// 处理具体的逻辑
}
job.cancel()

可以看到,我们先创建了一个Job对象,然后把它传入CoroutineScope()函数当中,注意这 里的CoroutineScope()是个函数,虽然它的命名更像是一个类。CoroutineScope()函数 会返回一个CoroutineScope对象,这种语法结构的设计更像是我们创建了一个 CoroutineScope的实例,可能也是Kotlin有意为之的。有了CoroutineScope对象之后,就 可以随时调用它的launch函数来创建一个协程了。

现在所有调用CoroutineScope的launch函数所创建的协程,都会被关联在Job对象的作用域 下面。这样只需要调用一次cancel()方法,就可以将同一作用域内的所有协程全部取消

但是launch函数只能用于执行一段逻辑,却不能获取执行的结果,因为它的返回值 永远是一个Job对象

async函数可以获得协程执行结果

async函数必须在协程作用域当中才能调用,它会创建一个新的子协程并返回一个Deferred对 象,如果我们想要获取async函数代码块的执行结果,只需要调用Deferred对象的await() 方法即可

fun main() {
    runBlocking {
        val result = async {
            5 + 5
        }.await()
        println(result)
    }
}

在调用了async函数之后,代码块中的代码就会 立刻开始执行。当调用await()方法时,如果代码块中的代码还没执行完,那么await()方法 会将当前协程阻塞住,直到可以获得async函数的执行结果

fun main() {
    runBlocking {
        val start = System.currentTimeMillis()
        val result1 = async {
            delay(1000)
            5 + 5
        }.await()
        val result2 = async {
            delay(1000)
            4 + 6
        }.await()
        println("result is ${result1 + result2}")
        val end = System.currentTimeMillis()
        println("cost ${end - start} ms.")
    }
}

// 运行结果
result is 20
cost 2010 ms.

这里连续使用了两个async函数来执行任务,并在代码块中调用delay()方法进行1秒的延迟。 按照刚才的理论,await()方法在async函数代码块中的代码执行完之前会一直将当前协程阻塞住

对上述写法进行修改提高效率

fun main() {
    runBlocking {
        val start = System.currentTimeMillis()
        val deferred1 = async {
            delay(1000)
            5 + 5
        }
        val deferred2 = async {
            delay(1000)
            4 + 6
        }
        println("result is ${deferred1.await() + deferred2.await()}")
        val end = System.currentTimeMillis()
        println("cost ${end - start} ms.")
    }
}
// 运行结果
result is 20
cost 1012 ms.
  • withContext()函数 withContext()函数是一个挂起函数,大体可以将它理解成async函数的一种简化版写法
fun main() {
    runBlocking {
        val result = withContext(Dispatchers.Default) {
            5 + 5
        }
       println(result)
    }
}

调用withContext()函数之后,会立即执行代码块中的代码,同时将 外部协程挂起。当代码块中的代码全部执行完之后,会将最后一行的执行结果作为 withContext()函数的返回值返回,因此基本上相当于val result = async{ 5 + 5 }.await()的写法。唯一不同的是,withContext()函数强制要求我们指定一个线程参数

Android中要求网络请求必须在子线程中进行,即使你开启了协程去执行网络请求,假如它是主线程当中的协程,那么程序仍然会出错。

这个时候我们就应该通过线程参数给协程指定一个具体的运行线程

线程参数主要有以下3种值可选:Dispatchers.Default、Dispatchers.IO和 Dispatchers.Main。Dispatchers.Default表示会使用一种默认低并发的线程策略,当 你要执行的代码属于计算密集型任务时,开启过高的并发反而可能会影响任务的运行效率,此 时就可以使用Dispatchers.Default。Dispatchers.IO表示会使用一种较高并发的线程策 略,当你要执行的代码大多数时间是在阻塞和等待中,比如说执行网络请求时,为了能够支持 更高的并发数量,此时就可以使用Dispatchers.IO。Dispatchers.Main则表示不会开启 子线程,而是在Android主线程中执行代码,但是这个值只能在Android项目中使用,纯Kotlin 程序使用这种类型的线程参数会出现错误。 事实上,在我们刚才所学的协程作用域构建器中,除了coroutineScope函数之外,其他所有 的函数都是可以指定这样一个线程参数的,只不过withContext()函数是强制要求指定的,而 其他函数则是可选的。

使用协程简化回调的写法

借助suspendCoroutine函数就能将传统回调机制的写法大幅简化

suspendCoroutine函数必须在协程作用域或挂起函数中才能调用,它接收一个Lambda表达 式参数,主要作用是将当前协程立即挂起,然后在一个普通的线程中执行Lambda表达式中的 代码。Lambda表达式的参数列表上会传入一个Continuation参数,调用它的resume()方 法或resumeWithException()可以让协程恢复执行

之前Retrofit的service的回调重写,使用susoendCoroutine重写

suspend fun <T> Call<T>.await(): T {
    return suspendCoroutine { continuation ->
        enqueue(object : Callback<T> {
            override fun onResponse(call: Call<T>, response: Response<T>) {
                val body = response.body()
                if (body != null) continuation.resume(body)
                else continuation.resumeWithException(
                    RuntimeException("response body is null")
                )
            }

            override fun onFailure(call: Call<T>, t: Throwable) {
                continuation.resumeWithException(t)
            }
        })
    }
}

之后使用Service接口

suspend fun getAppData() {
    try {
        val appList = ServiceCreator.create<AppService>().getAppData().await()
// 对服务器响应的数据进行处理
    } catch (e: Exception) {
// 对异常情况进行处理
    }
}

Material Design

ToolBar

自带的ActionBar来自AndroidManifest.xml文件内指定的theme,而theme在res文件夹 下的values文件夹的theme.xml文件内,theme制定了父theme为: Theme.MaterialComponents.DayNight.DarkActionBar 由此,每次创建项目都会带有ActionBar 此处修改theme文件父theme为NoActionBar Theme.AppCompat.Light.NoActionBar

修改activity_main.xml文件中的代码

<FrameLayout 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=".MainActivity">

    <androidx.appcompat.widget.Toolbar
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:id="@+id/toolbar"
        android:background="@color/purple_500"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
        app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
        />


</FrameLayout>

这里使用xmlns:app指定了一个新的命名空间。思考一下,正是由于每个布局文件都会使 用xmlns:android来指定一个命名空间,我们才能一直使用android:id、android: layout_width等写法。这里指定了xmlns:app,也就是说现在可以使用app:attribute这 样的写法了。但是为什么这里要指定一个xmlns:app的命名空间呢?这是由于许多Material属 性是在新系统中新增的,老系统中并不存在,那么为了能够兼容老系统,我们就不能使用 android:attribute这样的写法了,而是应该使用app:attribute。

由于我们刚才在styles.xml中将程序的 主题指定成了浅色主题,因此Toolbar现在也是浅色主题,那么Toolbar上面的各种元素就会自 动使用深色系,从而和主体颜色区别开。 此时如果设置ToolBar使用深色主题,弹出的菜单项也会是深色,所以二者都需要单独设置

但是之前使用ActionBar时文字都是白色的,现在变成 黑色的会很难看。那么为了能让Toolbar单独使用深色主题,这里我们使用了android:theme 属性,将Toolbar的主题指定成了ThemeOverlay.AppCompat.Dark.ActionBar。但是这样指 定之后又会出现新的问题,如果Toolbar中有菜单按钮,那么弹出 的菜单项也会变成深色主题,这样就再次变得十分难看了,于是这里又使用了 app:popupTheme属性,单独将弹出的菜单项指定成了浅色主题

修改MainActivity代码

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.materialtest.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var bind: ActivityMainBinding

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

        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)
        setSupportActionBar(bind.toolbar)
    }
}

在AndroidManifest.xml文件的label标签可以修改Toolbar中的显示的文字

创建一个菜单(menu) res文件夹下创建menu文件夹,再在menu文件夹下新建菜单资源文件

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
        android:id="@+id/backup"
        android:icon="@drawable/ic_backup"
        android:title="Backup"
        app:showAsAction="always"
        />

    <item
        android:id="@+id/delete"
        android:icon="@drawable/ic_delete"
        android:title="Delete"
        app:showAsAction="ifRoom"
        />

    <item
        android:id="@+id/settings"
        android:icon="@drawable/ic_settings"
        android:title="Settings"
        app:showAsAction="never"
        />

</menu>

我们通过标签来定义action按钮,android:id用于指定按钮的id, android:icon用于指定按钮的图标,android:title用于指定按钮的文字。 接着使用app:showAsAction来指定按钮的显示位置,这里之所以再次使用了app命名空间, 同样是为了能够兼容低版本的系统

always表示永远显示在Toolbar中,如果屏幕空间不够则不显示; ifRoom表示屏幕空间足够的情况下显示在Toolbar中,不够的话就显示在菜单当中; never则表示永远显示在菜单当中

注意,Toolbar中的action按钮只会显示图标,菜单中的action按钮只会显示文字。

修改MainActivity中的代码

override fun onCreateOptionsMenu(menu: Menu?): Boolean {
        menuInflater.inflate(R.menu.toolbar, menu)
        return true
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            R.id.backup -> Toast.makeText(this, "You Clicked Backup", Toast.LENGTH_SHORT).show()
            R.id.delete -> Toast.makeText(this, "You Clicked Delete", Toast.LENGTH_SHORT).show()
            R.id.settings -> Toast.makeText(this, "You Clicked Settings", Toast.LENGTH_SHORT).show()
        }
        return true
    }

滑动菜单

所谓的滑动菜单,就是将一些菜单选项隐藏起来,而不是放置在主屏幕上,然后可以通过滑动 的方式将菜单显示出来。这种方式既节省了屏幕空间,又实现了非常好的动画效果,是 Material Design中推荐的做法。

DrawerLayout

修改activity_main.xml中的代码使用DrawerLayout并且添加一个TextView

<androidx.drawerlayout.widget.DrawerLayout 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=".MainActivity">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <androidx.appcompat.widget.Toolbar
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:id="@+id/toolbar"
            android:background="@color/purple_500"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
            />
    </FrameLayout>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        android:background="#FFF"
        android:text="This is menu"
        android:textSize="30sp"
        />

</androidx.drawerlayout.widget.DrawerLayout>

先来简单介绍一下DrawerLayout的用法吧。首先它是一个布局, 在布局中允许放入两个直接子控件:第一个子控件是主屏幕中显示的内容,第二个子控件是滑动菜单中显示的内容

这里最外层的控件使用了DrawerLayout。DrawerLayout中放置了两个直接子控 件:第一个子控件是FrameLayout,用于作为主屏幕中显示的内容,当然里面还有我们刚刚定义的Toolbar;第二个子控件是一个TextView,用于作为滑动菜单中显示的内容,其实使用什 么都可以,DrawerLayout并没有限制只能使用固定的控件

但是关于第二个子控件有一点需要注意,layout_gravity这个属性是必须指定的,因为我们 需要告诉DrawerLayout滑动菜单是在屏幕的左边还是右边,指定left表示滑动菜单在左边,指 定right表示滑动菜单在右边。这里我指定了start,表示会根据系统语言进行判断,如果系统语 言是从左往右的,比如英语、汉语,滑动菜单就在左边,如果系统语言是从右往左的,比如阿 拉伯语,滑动菜单就在右边。

在Toolbar的最左边加入一个导航按钮,点击按钮也会将滑动菜 单的内容展示出来。这样就相当于给用户提供了两种打开滑动菜单的方式,防止一些用户不知 道屏幕的左侧边缘是可以拖动的

修改MainActivity中的代码

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

        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)
        setSupportActionBar(bind.toolbar)

        supportActionBar?.let {
            it.setDisplayHomeAsUpEnabled(true)
            it.setHomeAsUpIndicator(R.drawable.ic_menu)
        }
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            android.R.id.home -> bind.drawerLayout.openDrawer(GravityCompat.START)
	    ...
	    }
}

这里我们并没有改动多少代码,首先调用getSupportActionBar()方法得到了ActionBar的 实例,虽然这个ActionBar的具体实现是由Toolbar来完成的

在ActionBar不为空的情况下调用setDisplayHomeAsUpEnabled()方法让导航按钮显示出来

调用setHomeAsUpIndicator()方法来设置一个导航按钮图标

实际上,Toolbar最左侧的这个按 钮就叫作Home按钮,它默认的图标是一个返回的箭头,含义是返回上一个Activity。很明显, 这里我们将它默认的样式和作用都进行了修改。

接下来,在onOptionsItemSelected()方法中对Home按钮的点击事件进行处理,Home按 钮的id永远都是android.R.id.home。然后调用DrawerLayout的openDrawer()方法将滑 动菜单展示出来,注意,openDrawer()方法要求传入一个Gravity参数,为了保证这里的行 为和XML中定义的一致,我们传入了GravityCompat.START

NavigationView是Material库中提供的一个控件,它不仅是严格按照Material Design 的要求来设计的,而且可以将滑动菜单页面的实现变得非常简单

既然这个控件是Material库中提供的,那么我们就需要将这个库引入项目中才行

在app/build.gradle中引入依赖

runtimeOnly 'com.google.android.material:material:1.6.1'
implementation 'de.hdodenhof:circleimageview:3.1.0'

这里添加了两行依赖关系:第一行就是Material库,第二行是一个开源项目 CircleImageView,它可以用来轻松实现图片圆形化的功能

需要注意的是,当你引入了Material库之后,还需要将res/values/styles.xml文件中 AppTheme的parent主题改成Theme.MaterialComponents.Light.NoActionBar,否则在 使用接下来的一些控件时可能会遇到崩溃问题

在开始使用NavigationView之前,我们还需要准备好两个东西:menu和headerLayout。 menu是用来在NavigationView中显示具体的菜单项的,headerLayout则是用来在 NavigationView中显示头部布局的

在menu目录下新建nav_menu.xml文件

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <group android:checkableBehavior="single">
        <item
            android:id="@+id/navCall"
            android:icon="@drawable/nav_call"
            android:title="Call" />
        <item
            android:id="@+id/navFriends"
            android:icon="@drawable/nav_friends"
            android:title="Friends" />
        <item
            android:id="@+id/navLocation"
            android:icon="@drawable/nav_location"
            android:title="Location" />
        <item
            android:id="@+id/navMail"
            android:icon="@drawable/nav_mail"
            android:title="Mail" />
        <item
            android:id="@+id/navTask"
            android:icon="@drawable/nav_task"
            android:title="Tasks" />
    </group>
</menu>

中嵌套了一个标签,然后将group的checkableBehavior属性 指定为single。group表示一个组,checkableBehavior指定为single表示组中的所有菜 单项只能单选。

在layout文件夹下新建nav_header.xml文件

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="180dp"
    android:padding="10dp"
    android:background="@color/purple_500"
    >

    <de.hdodenhof.circleimageview.CircleImageView
        android:id="@+id/iconImage"
        android:layout_width="70dp"
        android:layout_height="70dp"
        android:src="@drawable/nav_icon"
        android:layout_centerInParent="true" />
    <TextView
        android:id="@+id/mailText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:text="tonygreendev@gmail.com"
        android:textColor="#FFF"
        android:textSize="14sp" />
    <TextView
        android:id="@+id/userText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_above="@id/mailText"
        android:text="Tony Green"
        android:textColor="#FFF"
        android:textSize="14sp" />

</RelativeLayout>

修改activity_main.xml文件中的代码

<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout 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:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <androidx.appcompat.widget.Toolbar
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:id="@+id/toolbar"
            android:background="@color/purple_500"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
            />
    </FrameLayout>


    <com.google.android.material.navigation.NavigationView
        android:id="@+id/navView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        app:menu="@menu/nav_menu"
        app:headerLayout="@layout/nav_header"
        />

</androidx.drawerlayout.widget.DrawerLayout>

修改MainAcitvity 处理菜单点击事件

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

        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)
        setSupportActionBar(bind.toolbar)

        supportActionBar?.let {
            it.setDisplayHomeAsUpEnabled(true)
            it.setHomeAsUpIndicator(R.drawable.ic_menu)
        }

        bind.navView.setCheckedItem(R.id.navCall)
        bind.navView.setNavigationItemSelectedListener {
            bind.drawerLayout.closeDrawers()
            true
        }
    }

代码还是比较简单的,这里我们首先调用了NavigationView的setCheckedItem()方法 将Call菜单项设置为默认选中

接着调用了setNavigationItemSelectedListener()方法 来设置一个菜单项选中事件的监听器,当用户点击了任意菜单项时,就会回调到传入的 Lambda表达式当中,我们可以在这里编写具体的逻辑处理。这里调用了DrawerLayout的 closeDrawers()方法将滑动菜单关闭,并返回true表示此事件已被处理。

悬浮按就和可交互提示

立面设计是Material Design中一条非常重要的设计思想,也就是说,按照Material Design的 理念,应用程序的界面不仅仅是一个平面,而应该是有立体效果的

FloatingActionButton

修改activity_main.xml中的代码

<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout 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:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <androidx.appcompat.widget.Toolbar
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:id="@+id/toolbar"
            android:background="@color/purple_500"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
            />
        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|end"
            android:layout_margin="16dp"
            android:src="@drawable/ic_done"/>
    </FrameLayout>


    <com.google.android.material.navigation.NavigationView
        android:id="@+id/navView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        app:menu="@menu/nav_menu"
        app:headerLayout="@layout/nav_header"
        />

</androidx.drawerlayout.widget.DrawerLayout>

layout_gravity属性指定将这个控件放置于屏幕的右下角其中end的工作原理和之前的 start是一样的,即如果系统语言是从左往右的,那么end就表示在右边,如果系统语言是从右 往左的,那么end就表示在左边

说到悬浮,其实我们还可以指定FloatingActionButton的悬浮高度

<com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|end"
            android:layout_margin="16dp"
            android:src="@drawable/ic_done"
            app:elevation="8dp"
            />

这里使用app:elevation属性给FloatingActionButton指定一个高度值。高度值越大,投影 范围也越大,但是投影效果越淡;高度值越小,投影范围也越小,但是投影效果越浓。

给按钮添加点击事件

bind.fab.setOnClickListener {
            Toast.makeText(this, "FAB clicked", Toast.LENGTH_SHORT).show()
        }

SnackBar

更先进的提示工具

首先要明确,Snackbar并不是Toast的替代品,它们有着不同的应用场景。Toast的作用是告诉 用户现在发生了什么事情,但用户只能被动接收这个事情,因为没有什么办法能让用户进行选 择。而Snackbar则在这方面进行了扩展,它允许在提示中加入一个可交互按钮,当用户点击按 钮的时候,可以执行一些额外的逻辑操作

Snackbar的用法也非常简单,它和Toast是基本相似的,只不过可以额外增加一个按钮的点击事件

修改MainActivity

bind.fab.setOnClickListener {
            Snackbar.make(it, "Data deleted", Snackbar.LENGTH_SHORT)
                .setAction("Undo") {
                    Toast.makeText(this, "Data restored", Toast.LENGTH_SHORT).show()
                }
                .show()
        }

这个Snackbar竟然将我们的悬浮按钮给遮挡住了。虽说也不是 什么重大的问题,因为Snackbar过一会儿就会自动消失,但这种用户体验总归是不友好的。有 没有什么办法能解决一下呢?当然有了,只需要借助CoordinatorLayout就可以轻松解决

CoordinatorLayout

CoordinatorLayout可以说是一个加强版的FrameLayout,由AndroidX库提供。它在普通情 况下的作用和FrameLayout基本一致,但是它拥有一些额外的Material能力

事实上,CoordinatorLayout可以监听其所有子控件的各种事件,并自动帮助我们做出最为合 理的响应。举个简单的例子,刚才弹出的Snackbar提示将悬浮按钮遮挡住了,而如果我们能让 CoordinatorLayout监听到Snackbar的弹出事件,那么它会自动将内部的 FloatingActionButton向上偏移,从而确保不会被Snackbar遮挡

修改activity_main.xml文件,只需要包FrameLayout修该即可

<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout 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:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <androidx.appcompat.widget.Toolbar
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:id="@+id/toolbar"
            android:background="@color/purple_500"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
            />
        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|end"
            android:layout_margin="16dp"
            android:src="@drawable/ic_done"
            app:elevation="8dp"
            />
    </androidx.coordinatorlayout.widget.CoordinatorLayout>


    <com.google.android.material.navigation.NavigationView
        android:id="@+id/navView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        app:menu="@menu/nav_menu"
        app:headerLayout="@layout/nav_header"
        />

</androidx.drawerlayout.widget.DrawerLayout>

不过我们回过头来再思考一下,刚才说的是CoordinatorLayout可以监听其所有子控件的各种 事件,但是Snackbar好像并不是CoordinatorLayout的子控件吧,为什么它却可以被监听到 呢? 其实道理很简单,还记得我们在Snackbar的make()方法中传入的第一个参数吗?这个参数就 是用来指定Snackbar是基于哪个View触发的,刚才我们传入的是FloatingActionButton本 身,而FloatingActionButton是CoordinatorLayout中的子控件,因此这个事件就理所应当能被监听到了

卡片式布局

MaterialCardView

MaterialCardView是用于实现卡片式布局效果的重要控件,由Material库提供。实际上, MaterialCardView也是一个FrameLayout,只是额外提供了圆角和阴影等效果,看上去会有 立体的感觉。

MaterialCardView基本用法

<com.google.android.material.card.MaterialCardView
		android:layout_width="match_parent"
		android:layout_height="wrap_content"
		app:cardCornerRadius="4dp"
		app:elevation="5dp">
	<TextView
		android:id="@+id/infoText"
		android:layout_width="match_parent"
		android:layout_height="wrap_content"/>
</com.google.android.material.card.MaterialCardView>

使用recyclerview, 在app/build.gradle中添加依赖

runtimeOnly 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'com.github.bumptech.glide:glide:4.13.2'

上述声明的第二行是添加了Glide库的依赖。Glide是一个超级强大的开源图片加载库,它不仅 可以用于加载本地图片,还可以加载网络图片、GIF图片甚至是本地视频。最重要的是,Glide 的用法非常简单,只需几行代码就能轻松实现复杂的图片加载功能,因此这里我们准备用它来 加载水果图片

修改activity_main.xml文件,添加RecyclerView

<androidx.drawerlayout.widget.DrawerLayout 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:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <androidx.appcompat.widget.Toolbar
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:id="@+id/toolbar"
            android:background="@color/purple_500"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
            />
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerview"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            />

        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|end"
            android:layout_margin="16dp"
            android:src="@drawable/ic_done"
            app:elevation="8dp"
            />
    </androidx.coordinatorlayout.widget.CoordinatorLayout>


    <com.google.android.material.navigation.NavigationView
        android:id="@+id/navView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        app:menu="@menu/nav_menu"
        app:headerLayout="@layout/nav_header"
        />

</androidx.drawerlayout.widget.DrawerLayout>

新建Fruit实体类

class Fruit(val name: String, val imageId: Int)

layout文件夹下新建fruit_item.xml文件

<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="4dp"
    >

    <LinearLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        >
        <ImageView
            android:id="@+id/fruitImage"
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:scaleType="centerCrop"/>
        <TextView
            android:id="@+id/fruitName"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:layout_margin="5dp"
            android:textSize="16sp"
            />
    </LinearLayout>

</com.google.android.material.card.MaterialCardView>

在ImageView中我们使用了一个scaleType属性,这个属性可以指定图片的缩放模式

这里使用了centerCrop模式,它可以让图片保持原有比例填充满ImageView,并将超出 屏幕的部分裁剪掉。

实现FruitAdapter类

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide

class Fruit(val name: String, val imageId: Int)

class FruitAdapter(private val context: Context, private val fruitList: List<Fruit>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
        val fruitName: TextView = view.findViewById(R.id.fruitName)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val view = LayoutInflater.from(context).inflate(R.layout.fruit_item, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val fruit = fruitList[position]
        (holder as ViewHolder).fruitName.text = fruit.name
        Glide.with(context).load(fruit.imageId).into(holder.fruitImage)
    }

    override fun getItemCount() = fruitList.size

}

首先调用Glide.with()方法并传入一个Context、Activity或Fragment参数, 然后调用load()方法加载图片,可以是一个URL地址,也可以是一个本地路径,或者是一个资 源id,最后调用into()方法将图片设置到具体某一个ImageView中就可以了

修改MainActivity中的代码

val fruits = mutableListOf(Fruit("Apple", R.drawable.apple), Fruit("Banana",
        R.drawable.banana), Fruit("Orange", R.drawable.orange), Fruit("Watermelon",
        R.drawable.watermelon), Fruit("Pear", R.drawable.pear), Fruit("Grape",
        R.drawable.grape), Fruit("Pineapple", R.drawable.pineapple), Fruit("Strawberry",
        R.drawable.strawberry), Fruit("Cherry", R.drawable.cherry), Fruit("Mango",
        R.drawable.mango))
    val fruitList = ArrayList<Fruit>()

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

        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)
        setSupportActionBar(bind.toolbar)

        supportActionBar?.let {
            it.setDisplayHomeAsUpEnabled(true)
            it.setHomeAsUpIndicator(R.drawable.ic_menu)
        }
	initFruits()
        val layoutManager = GridLayoutManager(this, 2)
        bind.recyclerview.layoutManager = layoutManager
        val adapter = FruitAdapter(this, fruitList)
        bind.recyclerview.adapter = adapter
}

private fun initFruits() {
        fruitList.clear()
        repeat(50) {
            val index = (0 until fruits.size).random()
            fruitList.add(fruits[index])
        }
}

此时运行,ToolBar被挡住了,借助AppBarLayout来解决

AppBarLayout

因为RecyclerView和ToolBar都放在CoordinatorLayout中,而CoordinatorLayout就是 一个加强版的FrameLayout,那么FrameLayout中的所有控件在不进行明确定位的情况 下,默认都会摆放在布局的左上角,从而产生了遮挡的现象。

这里我准备使用Material库中提供的另外一个工具——AppBarLayout。AppBarLayout实际 上是一个垂直方向的LinearLayout,它在内部做了很多滚动事件的封装,并应用了一些 Material Design的设计理念。

那么我们怎样使用AppBarLayout才能解决前面的遮挡问题呢?其实只需要两步就可以了, 第一步将Toolbar嵌套到AppBarLayout中 第二步给RecyclerView指定一个布局行为

修改activity_main.xml文件

<com.google.android.material.appbar.AppBarLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <androidx.appcompat.widget.Toolbar
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:id="@+id/toolbar"
                android:background="@color/purple_500"
                android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
                />
        </com.google.android.material.appbar.AppBarLayout>
	...
<androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerview"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior"
            />

当RecyclerView滚动的时候就已经将滚动事件通知给AppBarLayout了,只 是我们还没进行处理而已

当AppBarLayout接收到滚动事件的时候,它内部的子控件其实是可以指定如何去响应这些事 件的,通过app:layout_scrollFlags属性就能实现

修改activity_main.xml文件

<com.google.android.material.appbar.AppBarLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <androidx.appcompat.widget.Toolbar
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:id="@+id/toolbar"
                android:background="@color/purple_500"
                android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
                app:layout_scrollFlags="scroll|enterAlways|snap"
                />
        </com.google.android.material.appbar.AppBarLayout>

这里在Toolbar中添加了一个app:layout_scrollFlags属性,并将这个属性的值指定成了 scroll|enterAlways|snap。其中, scroll表示当RecyclerView向上滚动的时候,Toolbar会跟着一起向上滚动并实现隐藏 ; enterAlways表示当RecyclerView向下滚动的时候,Toolbar会跟着一起向下滚动并重新显示; snap表示当Toolbar还没有完全隐藏或显示的时候,会根据当前滚动的距离,自动选择是隐藏还是显示

下拉刷新

SwipeRefreshLayout就是用于实现下拉刷新功能的核心类,我们把想要实现下拉刷新功能的 控件放置到SwipeRefreshLayout中,就可以迅速让这个控件支持下拉刷新。那么在 MaterialTest项目中,应该支持下拉刷新功能的控件自然就是RecyclerView了

使用SwipeRefreshLayout需要在app/build.gradle中添加依赖

runtimeOnly 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'

修改activity_main.xml文件

<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior"
            >
            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/recyclerview"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:layout_behavior="@string/appbar_scrolling_view_behavior"
                />
        </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

可以看到,这里我们在RecyclerView的外面又嵌套了一层SwipeRefreshLayout,这样 RecyclerView就自动拥有下拉刷新功能了。另外需要注意,由于RecyclerView现在变成了SwipeRefreshLayout的子控件,因此之前使用app:layout_behavior声明的布局行为现在 也要移到SwipeRefreshLayout中才行。

不过这还没有结束,虽然RecyclerView已经支持下拉刷新功能了,但是我们还要在代码中处理 具体的刷新逻辑才行。修改MainActivity中的代码

bind.swipeRefresh.setColorSchemeResources(R.color.purple_500)
        bind.swipeRefresh.setOnRefreshListener {
            refreshFruits(adapter)
        }
    }

    private fun refreshFruits(adapter: FruitAdapter) {
        thread {
           Thread.sleep(2000)
            runOnUiThread {
                initFruits()
                adapter.notifyDataSetChanged()
                bind.swipeRefresh.isRefreshing = false
            }
        }
    }

可折叠式标题栏

CollapsingToolbarLayout

CollapsingToolbarLayout是不能独立存在的,它在设计的时候就被限定只能作为 AppBarLayout的直接子布局来使用。而AppBarLayout又必须是CoordinatorLayout的子布局,

由于整个布局文件比较复杂,这里我准备采用分段编写的方式。activity_fruit.xml中的内容主 要分为两部分,一个是水果标题栏,一个是水果内容详情

最外层使用CoordinatorLayout

<androidx.coordinatorlayout.widget.CoordinatorLayout 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=".FruitActivity">

</androidx.coordinatorlayout.widget.CoordinatorLayout>

在CoordinatorLayout嵌套一个AppBarLayout

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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=".FruitActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appBar"
        android:layout_width="match_parent"
        android:layout_height="250dp"
        >

    </com.google.android.material.appbar.AppBarLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

再在AppBarLayout中嵌套一个CollpasingToolBarLayout

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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=".FruitActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appBar"
        android:layout_width="match_parent"
        android:layout_height="250dp"
        >

       <com.google.android.material.appbar.CollapsingToolbarLayout
           android:id="@+id/collpasingToolbar"
           android:layout_width="match_parent"
           android:layout_height="match_parent"
           android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
           app:contentScrim="@color/purple_500"
           app:layout_scrollFlags="scroll|exitUntilCollapsed">
       </com.google.android.material.appbar.CollapsingToolbarLayout>

    </com.google.android.material.appbar.AppBarLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

android:theme属性指定了一个ThemeOverlay.AppCompat.Dark.ActionBar的主题,其 实对于这部分我们也并不陌生,因为之前在activity_main.xml中给Toolbar指定的也是这个 主题,只不过这里要实现更加高级的Toolbar效果,因此需要将这个主题的指定提到上一层来。 app:contentScrim属性用于指定CollapsingToolbarLayout在趋于折叠状态以及折叠之后的 背景色,其实CollapsingToolbarLayout在折叠之后就是一个普通的Toolbar,那么背景色肯定 应该是purple_500了

exitUntilCollapsed表示当CollapsingToolbarLayout随着滚动完成折叠之后就保留在 界面上,不再移出屏幕。

在CollpasingToolBarLayout中定义标题栏的具体内容

 <com.google.android.material.appbar.CollapsingToolbarLayout
           android:id="@+id/collpasingToolbar"
           android:layout_width="match_parent"
           android:layout_height="match_parent"
           android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
           app:contentScrim="@color/purple_500"
           app:layout_scrollFlags="scroll|exitUntilCollapsed">

           <ImageView
               android:id="@+id/fruitImageView"
               android:layout_width="match_parent"
               android:layout_height="match_parent"
               android:scaleType="centerCrop"
               app:layout_collapseMode="parallax"/>

           <androidx.appcompat.widget.Toolbar
               android:id="@+id/toolbar"
               android:layout_width="match_parent"
               android:layout_height="?attr/actionBarSize"
               app:layout_collapseMode="pin"
               />

       </com.google.android.material.appbar.CollapsingToolbarLayout>

app:layout_collapseMode它用于指定当前控件在CollapsingToolbarLayout折叠过程 中的折叠模式,其中Toolbar指定成 pin,表示在折叠的过程中位置始终保持不变,ImageView指定成parallax,表示会在 折叠的 过程中产生一定的错位偏移,这种模式的视觉效果会非常好

开始编写水果内容详情部分,继续修改activity_main.xml文件内容

...
</com.google.android.material.appbar.AppBarLayout>

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
    </androidx.core.widget.NestedScrollView>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

ScrollView的用法: 它允许使用滚动的方式来查看屏幕以外的数据,而NestedScrollView在此基础之上还增加了嵌套响应滚动事件的功能。 由于CoordinatorLayout本身已经可以响应滚动事件了,因此我们在它的内部就需要使用 NestedScrollView或RecyclerView这样的布局。另外,这里还通过app:layout_behavior 属性指定了一个布局行为,这和之前在RecyclerView中的用法是一模一样的。

不管是ScrollView还是NestedScrollView,它们的内部都只允许存在一个直接子布局。因此, 如果我们想要在里面放入很多东西的话,通常会先嵌套一个LinearLayout,然后再在 LinearLayout中放入具体的内容就可以了

<androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <LinearLayout
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

        </LinearLayout>

    </androidx.core.widget.NestedScrollView>

接下来在LinearLayout中放入具体的内容,这里我准备使用一个TextView来显示水果的内容详 情,并将TextView放在一个卡片式布局当中

<LinearLayout
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <com.google.android.material.card.MaterialCardView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginBottom="15dp"
                android:layout_marginLeft="15dp"
                android:layout_marginRight="15dp"
                android:layout_marginTop="35dp"
                app:cardCornerRadius="4dp">
                <TextView
                    android:id="@+id/fruitContentText"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_margin="100dp"/>
            </com.google.android.material.card.MaterialCardView>
        </LinearLayout>

在activity_fruit.xml中加入一个悬浮按钮了。这个界面是一个水果详 情展示界面,那么我就加入一个表示评论作用的悬浮按钮吧。

修改activity_fruit.xml文件中的代码

...
<com.google.android.material.floatingactionbutton.FloatingActionButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:src="@drawable/ic_comment"
        app:layout_anchor="@id/appBar"
        app:layout_anchorGravity="bottom|end"
        />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

FloatingActionButton中使用app:layout_anchor属性指定 了一个锚点,我们将锚点设置为AppBarLayout,这样悬浮按钮就会出现在水果标题栏的区域 内,接着又使用app:layout_anchorGravity属性将悬浮按钮定位在标题栏区域的右下角

修改FruitActivity中的代码

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.MenuItem
import com.bumptech.glide.Glide
import com.example.materialtest.databinding.ActivityFruitBinding

class FruitActivity : AppCompatActivity() {

    private lateinit var bind: ActivityFruitBinding

    companion object {
        const val FRUIT_NAME = "fruit_name"
        const val FRUIT_IMAGE_ID = "fruit_image_id"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityFruitBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)

        val fruitName = intent.getStringExtra(FRUIT_NAME) ?: ""
        val fruitImageId = intent.getIntExtra(FRUIT_IMAGE_ID, 0)
        setSupportActionBar(bind.toolbar)
        supportActionBar?.setDisplayHomeAsUpEnabled(true)
        bind.collpasingToolbar.title = fruitName
        Glide.with(this).load(fruitImageId).into(bind.fruitImageView)
        bind.fruitContentText.text = generateFruitContent(fruitName)
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            android.R.id.home -> {
                finish()
                return true
            }
        }
        return super.onOptionsItemSelected(item)
    }

    private fun generateFruitContent(fruitName: String) = fruitName.repeat(500)
}

修改FruitActivity中RecyclerView的点击事件监听

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val view = LayoutInflater.from(context).inflate(R.layout.fruit_item, parent, false)
        val holder = ViewHolder(view)
        holder.itemView.setOnClickListener {
            val position = holder.absoluteAdapterPosition
            val fruit = fruitList[position]
            val intent = Intent(context, FruitActivity::class.java).apply {
                putExtra(FruitActivity.FRUIT_NAME, fruit.name)
                putExtra(FruitActivity.FRUIT_IMAGE_ID, fruit.imageId)
            }
            context.startActivity(intent)
        }
        return holder
}

充分利用系统状态栏空间

如果我们能将背景图和状态栏融合到一起,那这个视觉体验绝对能提升好几个档次 不过,在Android 5.0系统之前,我们是无法对状态栏的背景或颜色进行操作的,那个时候也还 没有Material Design的概念,但是Android 5.0及之后的系统都是支持这个功能的。恰好我们 整本书的所有代码最低兼容的就是Android 5.0系统,因此这里完全可以进一步地提升视觉体验。

想要让背景图能够和系统状态栏融合,需要借助 android:fitsSystemWindows 这个属性来 实现。在CoordinatorLayout、AppBarLayout、CollapsingToolbarLayout这种嵌套结构的 布局中,将控件的android:fitsSystemWindows属性指定成true,就表示该控件会出现在 系统状态栏里。对应到我们的程序,那就是水果标题栏中的ImageView应该设置这个属性了。 不过只给ImageView设置这个属性是没有用的,我们必须将 ImageView布局结构中的所有父布局 都设置上这个属性才可以,

修改activity_fruit.xml

<androidx.coordinatorlayout.widget.CoordinatorLayout 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"
    android:fitsSystemWindows="true"
    tools:context=".FruitActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appBar"
        android:layout_width="match_parent"
        android:layout_height="250dp"
        android:fitsSystemWindows="true"
        >

       <com.google.android.material.appbar.CollapsingToolbarLayout
           android:id="@+id/collpasingToolbar"
           android:layout_width="match_parent"
           android:layout_height="match_parent"
           android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
           app:contentScrim="@color/purple_500"
           android:fitsSystemWindows="true"
           app:layout_scrollFlags="scroll|exitUntilCollapsed">

           <ImageView
               android:id="@+id/fruitImageView"
               android:layout_width="match_parent"
               android:layout_height="match_parent"
               android:fitsSystemWindows="true"
               android:scaleType="centerCrop"
               app:layout_collapseMode="parallax"/>
	       ...

但是,即使我们将android:fitsSystemWindows属性都设置好了也没有用,因为还必须在 程序的主题中将状态栏颜色指定成透明色才行。指定成透明色的方法很简单,在主题中将 android:statusBarColor属性的值指定成@android:color/transparent就可以了。

在res/values/styles.xml文件内加入 此处parent的值为上方style的值

<style name="FruitActivityTheme" parent="Theme.MaterialTest">
        <item name="android:statusBarColor">@android:color/transparent</item>
    </style>

这里我们定义了一个FruitActivityTheme主题,它是专门给FruitActivity使用的。 FruitActivityTheme的父主题是Theme.MaterialTest,也就是说,它继承了AppTheme中的所有特 性。在此基础之上,我们将FruitActivityTheme中的状态栏的颜色指定成透明色。

修改AndroidManifest.xml文件,修改主题

<activity
            android:name=".FruitActivity"
            android:theme="@style/FruitActivityTheme"
            android:exported="false" />

这里使用android:theme属性单独给FruitActivity指定了FruitActivityTheme这个主题,这 样我们就大功告成了

Kotlin 编写好用的工具方法

求N个数的最大最小值

因为kotlin内置的max只能比较两个数所以如果需要比较多个需要自己编写 新建Max.kt文件

fun max(vararg nums: Int): Int {
    var maxNum = Int.MIN_VALUE
    for (num in nums) {
        maxNum = kotlin.math.max(maxNum, num)
    }
    return maxNum
}

Java中规定,所有类型的数字都是可比较的,因此必须实现Comparable接口,这个规则在 Kotlin中也同样成立。那么我们就可以借助泛型,将max()函数修改成接收任意多个实现 Comparable接口的参数

fun <T: Comparable<T>> max(vararg nums: T): T {
    if (nums.isEmpty()) throw RuntimeException("Params can not be empty")
    var maxNum = nums[0]
    for (num in nums) {
        if (num > maxNum) {
            maxNum = num
        }
    }
    return maxNum
}

可以看到,这里将泛型T的上界指定成了Comparable,那么参数T就必然是 Comparable的子类型了

简化Toast的用法

fun String.showToast(context: Context) {
    Toast.makeText(context, this, Toast.LENGTH_SHORT).show()
}


fun Int.showToast(context: Context) {
    Toast.makeText(context, this, Toast.LENGTH_SHORT).show()
}

弹出一段文字可以这么写

"This is Toast".showToast(context)

如果你想弹出一个定义在strings.xml中的字符串资源,也非常简单

R.string.app_name.showToast(context)

使用函数默认值解决默认参数问题,Toast.LENGTH_SHORT 还是Toast.LENGTH_LONG

fun String.showToast(context: Context, duration: Int = Toast.LENGTH_SHORT) {
    Toast.makeText(context, this, duration).show()
}


fun Int.showToast(context: Context, duration: Int = Toast.LENGTH_SHORT) {
    Toast.makeText(context, this, duration).show()
}

简化Snackbar的用法

由于make()方法接收一个View参数,Snackbar会使用这个View自动查找最外层的布局,用 于展示Snackbar。因此,我们就可以给View类添加一个扩展函数,并在里面封装显示 Snackbar的具体逻辑

新建Snackbar.kt文件

import android.view.View
import com.google.android.material.snackbar.Snackbar

fun View.showSnackbar(text: String, duration: Int = Snackbar.LENGTH_SHORT) {
   Snackbar.make(this, text, duration).show()
}

fun View.showSnackbar(resId: Int, duration: Int = Snackbar.LENGTH_SHORT) {
    Snackbar.make(this, resId, duration).show()
}

我们可以让showSnackbar()函数再额外接 收一个函数类型参数,以此来实现Snackbar的完整功能支持

import android.view.View
import com.google.android.material.snackbar.Snackbar

fun View.showSnackbar(text: String, actionText: String? = null, duration: Int = Snackbar.LENGTH_SHORT, block: (()-> Unit)? = null) {
   val snackbar = Snackbar.make(this, text, duration)
    if (actionText != null && block != null) {
        snackbar.setAction(actionText) {
            block()
        }
    }
    snackbar.show()
}

fun View.showSnackbar(resId: Int, actionText: String? = null, duration: Int = Snackbar.LENGTH_SHORT, block: (()-> Unit)? = null) {
    val snackbar = Snackbar.make(this, resId, duration)
    if (actionText != null && block != null) {
        snackbar.setAction(actionText) {
            block()
        }
    }
    snackbar.show()
}

Git 版本控制工具的高级用法

分支的用法

查看所有分支

git branch

创建一个分支

git branch version1.0

这样就创建了一个名为version1.0的分支

需要注意的是,在version1.0分支上修改并提交的代码将不会影响到master分支。同样的道 理,在master分支上修改并提交的代码也不会影响到version1.0分支。因此,如果我们在 version1.0分支上修复了一个bug,在master分支上这个bug仍然是存在的。这时将修改的代 码一行行复制到master分支上显然不是一种聪明的做法,最好的办法就是使用merge命令来完 成合并操作

git checkout master
git merge verison1.0

删除分支

git branch -D version1.0

与远程版本库协作

比如说现在有一个远程版本库的Git地址是https://github.com/example/test.git,就可以使 用如下命令将代码下载到本地

git clone https://github.com/example/test.git

把本地修改同步到远程版本库

git push origin master

origin部分指定的是远程版本库的Git地址,master部分指定的是同步到哪一个分支上

将远程版本库上的修改同步到本地

git fetch origin master

执行完这个命令后,就会将远程版本库上的代码同步到本地。不过同步下来的代码并不会合并 到任何分支上,而是会存放到一个origin/master分支上,这时我们可以通过diff命令来查 看远程版本库上到底修改了哪些东西

git diff origin/master

之后再调用merge命令将origin/master分支上的修改合并到主分支上即可

git merge origin/master

而pull命令则是相当于将fetch和merge这两个命令放在一起执行了,它可以从远程版本库上 获取最新的代码并且合并到本地

git pull origin master

高级程序开发组件,探究Jetpack

ViewModel

而ViewModel的一个重要作用就是可以帮助Activity分担一部分工作,它是专门用于存放与界 面相关的数据的。也就是说,只要是界面上能看得到的数据,它的相关变量都应该存放在 ViewModel中,而不是Activity中,这样可以在一定程度上减少Activity中的逻辑。 另外,ViewModel还有一个非常重要的特性。我们都知道,当手机发生横竖屏旋转的时候, Activity会被重新创建,同时存放在Activity中的数据也会丢失。而ViewModel的生命周期和 Activity不同,它可以保证在手机屏幕发生旋转的时候不会被重新创建,只有当Activity退出的 时候才会跟着Activity一起销毁。因此,将与界面相关的变量存放在ViewModel当中,这样即 使旋转手机屏幕,界面上显示的数据也不会丢失

ViewModel生命周期
#### ViewModel的基本用法 由于Jetpack中的组件通常是以AndroidX库的形式发布的,因此一些常用的Jetpack组件会在创 建Android项目时自动被包含进去。不过如果我们想要使用ViewModel组件,还需要在 app/build.gradle文件中添加如下依赖 ```groovy implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' ``` 通常来讲,比较好的编程规范是**给每一个Activity和Fragment都创建一个对应的ViewModel,** 因此这里我们就为MainActivity创建一个对应的MainViewModel类,并让它继承自 ViewModel

此处实现一个计数器功能 修改activity_main.xml文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">


    <TextView
        android:id="@+id/infoText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:textSize="32sp"
        />

    <Button
        android:id="@+id/plusOneBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Plus One"
        />

</LinearLayout>

修改MainActivity中的代码

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.jetpacktest.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var bind: ActivityMainBinding
    private lateinit var viewModel: MainViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)

        viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
        bind.plusOneBtn.setOnClickListener {
            viewModel.counter++
            refreshCounter()
        }
        refreshCounter()
    }

    private fun refreshCounter() {
        bind.infoText.text = viewModel.counter.toString()
    }
}

这里最需要注意的是,我们绝对不可以直接去创建ViewModel的实例,而是一定要通过 ViewModelProvider来获取ViewModel的实例

get方法内部会将viewmodel存储,下次调用get时会检查是否已经创建,如果已经创建,则直接取出

ViewModelProvider(<你的Activity实例>).get(<你的ViewModel实例>::class.java)

之所以要这么写,是因为ViewModel有其独立的生命周期,并且其生命周期要长于Activity。 如果我们在onCreate()方法中创建ViewModel的实例,那么每次onCreate()方法执行的时 候,ViewModel都会创建一个新的实例,这样当手机屏幕发生旋转的时候,就无法保留其中的数据了。

向ViewModel传递参数

现在的计数器虽然在屏幕旋转的时候不会丢失数据,但是如果退出程序之后再重新打开,那么 之前的计数就会被清零了。接下来我们就对这一功能进行升级,保证即使在退出程序后又重新 打开的情况下,数据仍然不会丢失

修改MainViewModel中的代码

class MainViewModel(countReserved: Int): ViewModel() {
    var counter = countReserved
}

借助ViewModelProvider.Factory接口向MainViewModel构造函数传递数据

新建一个MainViewModelFactory类,并让它实现ViewModelProvider.Factory接口

class MainViewModelFactory(private val countReserved: Int):
    ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return MainViewModel(countReserved) as T
    }
}

create()方法的执行时机和Activity的生命周期无关

在activity_main.xml中添加清零按钮

<Button
        android:id="@+id/clearBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Plus One"
        />

修改MainActivity中的代码

import android.content.Context
import android.content.SharedPreferences
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.jetpacktest.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var bind: ActivityMainBinding
    private lateinit var viewModel: MainViewModel
    private lateinit var sp: SharedPreferences

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)

        sp = getPreferences(Context.MODE_PRIVATE)
        val countReserved = sp.getInt("count_reserved", 0)
        viewModel = ViewModelProvider(this, MainViewModelFactory(countReserved)).get(MainViewModel::class.java)


        bind.clearBtn.setOnClickListener {
            viewModel.counter = 0
            refreshCounter()
        }

        bind.plusOneBtn.setOnClickListener {
            viewModel.counter++
            refreshCounter()
        }
        refreshCounter()
    }

    override fun onPause() {
        super.onPause()
        sp.edit {
            putInt("count_reserved", viewModel.counter)
        }
    }

    private fun refreshCounter() {
        bind.infoText.text = viewModel.counter.toString()
    }
}

Lifecycles

在非Activity类中感知Activity的生命周期

新建MyObserver类实现LifecycleObserver接口

import androidx.lifecycle.LifecycleObserver

class MyObserver: LifecycleObserver {
}

LifecycleObserver是一个空方法接口,只需要进行一下接口实现声明就可以了,而不去重 写任何方法

接下来我们可以在MyObserver中定义任何方法,但是如果想要感知到Activity的生命周期,还 得借助额外的注解功能才行。比如这里还是定义activityStart()和activityStop()这两 个方法

class MyObserver: LifecycleObserver {

    @OnLifecycleEvent(Lifecycle.Event.ON_START)
    fun activityStart() {
        Log.d("MyObserver", "activityStart")
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
    fun activityStop() {
        Log.d("MyObserver", "activity stop")
    }
}

注意此处 @OnLifecycleEvent 注解已经被弃用

可以看到,我们在方法上使用了@OnLifecycleEvent注解,并传入了一种生命周期事件。生 命周期事件的类型一共有7种:ON_CREATE、ON_START、ON_RESUME、ON_PAUSE、 ON_STOP和ON_DESTROY分别匹配Activity中相应的生命周期回调;另外还有一种ON_ANY类 型,表示可以匹配Activity的任何生命周期回调。

使用LifecycleOwner让MyObserver得到通知

lifecycleOwner.lifecycle.addObserver(MyObserver())

当然,我们可以自己去实现一个LifecycleOwner,但通常情况下这是完全没有必要的。因为只 要你的Activity是继承自AppCompatActivity的,或者你的Fragment是继承自 androidx.fragment.app.Fragment的,那么它们本身就是一个LifecycleOwner的实例, 这部分工作已经由AndroidX库自动帮我们完成了。也就是说,在MainActivity当中就可以这样写

class MainActivity : AppCompatActivity() {

    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)
        lifecycle.addObserver(MyObserver())
	...
	}
}

目前MyObserver虽然能够感知到Activity的生 命周期发生了变化,却没有办法主动获知当前的生命周期状态。要解决这个问题也不难,只需 要在MyObserver的构造函数中将Lifecycle对象传进来即可

class MyObserver(val lifecycle: Lifecycle): LifecycleObserver {

}

// MainActivity中可以这么写
lifecycle.addObserver(MyObserver(lifecycle))

有了Lifecycle对象之后,我们就可以在任何地方调用lifecycle.currentState来主动获 知当前的生命周期状态。lifecycle.currentState返回的生命周期状态是一个枚举类型, 一共有INITIALIZED、DESTROYED、CREATED、STARTED、RESUMED这5种状态类型,

LiveData

LiveData是Jetpack提供的一种响应式编程组件,它可以包含任何类型的数据,并在数据发生 变化的时候通知给观察者。LiveData特别适合与ViewModel结合在一起使用,虽然它也可以单 独用在别的地方,但是在绝大多数情况下,它是使用在ViewModel当中的

LiveData的基本使用方法

如果我们将计数器的计数使用LiveData来包装,然后在Activity中去观察它,就可以 主动将数据变化通知给Activity了

修改MainViewModel中的代码

import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

class MainViewModel(countReserved: Int): ViewModel() {

    var counter = MutableLiveData<Int>()

    init {
        counter.value = countReserved
    }

    fun plusOne() {
        val count = counter.value ?: 0
        counter.value = count + 1
    }

    fun clear() {
        counter.value = 0
    }
}

MutableLiveData是一种可变的LiveData,它的用法很简单,主要 有3种读写数据的方法,分别是getValue() setValue()postValue() 方法。 getValue()方法用于获取LiveData中包含的数据; setValue()方法用于给LiveData设置数据,但是只能在主线程中调用; postValue()方法用于在非主线程中给LiveData设置数据。 而上述代码其实就是调用getValue()和setValue()方法对应的语法糖写法

修改MainActivity中的代码

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (!::bind.isInitialized) {
            bind = ActivityMainBinding.inflate(layoutInflater)
        }
        setContentView(bind.root)
        lifecycle.addObserver(MyObserver(lifecycle))

        sp = getPreferences(Context.MODE_PRIVATE)
        val countReserved = sp.getInt("count_reserved", 0)
        viewModel = ViewModelProvider(this, MainViewModelFactory(countReserved)).get(MainViewModel::class.java)


        bind.clearBtn.setOnClickListener {
            viewModel.clear()
        }

        bind.plusOneBtn.setOnClickListener {
            viewModel.plusOne()
        }

        viewModel.counter.observe(this, Observer {
            count -> bind.infoText.text = count.toString()
        })
    }

    override fun onPause() {
        super.onPause()
        sp.edit {
            putInt("count_reserved", viewModel.counter.value ?: 0)
        }
    }

不过需要注意的是,如果你需要在子线程中给LiveData设置数据,一定要调用postValue()方法, 而不能再使用setValue()方法,否则会发生崩溃

在2019年的Google I/O大会上,Android团队官宣了Kotlin First,并且承诺未来会在 Jetpack中提供更多专门面向Kotlin语言的API。其中,lifecycle-livedata-ktx就是一个专门为 Kotlin语言设计的库,这个库在2.2.0版本中加入了对observe()方法的语法扩展。我们只需要 在app/build.gradle文件中添加如下依赖

implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.5.0"

之后可以使用如下语法

viewModel.counter.observe(this)  {
            count -> bind.infoText.text = count.toString()
}

虽说现在的写法可以正常工作,但其实这仍然不是最规范的 LiveData用法,主要的问题就在于我们将counter这个可变的LiveData暴露给了外部。这样 即使是在ViewModel的外面也是可以给counter设置数据的,从而破坏了ViewModel数据的 封装性,同时也可能带来一定的风险

比较推荐的做法是,永远只暴露不可变的LiveData给外部。这样在非ViewModel中就只能观察 LiveData的数据变化,而不能给LiveData设置数据

修改MainViewModel

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

class MainViewModel(countReserved: Int) : ViewModel() {

    val counter: LiveData<Int>
        get() = _counter

    private val _counter = MutableLiveData<Int>()

    init {
        _counter.value = countReserved
    }

    fun plusOne() {
        val count = _counter.value ?: 0
        _counter.value = count + 1
    }

    fun clear() {
        _counter.value = 0
    }
}

可以看到,这里先将原来的counter变量改名为_counter变量,并给它加上private修饰 符,这样_ counter变量对于外部就是不可见的了。然后我们又新定义了一个counter变量, 将它的类型声明为不可变的LiveData,并在它的get()属性方法中返回_ counter变量

目前这种写法可以说是非常规范了,这也是Android官方最为推荐的写法,希望你能好好掌握

map和switchMap

LiveData的基本用法虽说可以满足大部分的开发需求,但是当项目变得复杂之后,可能会出现 一些更加特殊的需求。LiveData为了能够应对各种不同的需求场景,提供了两种转换方法: map()和switchMap()方法

map

map: 是将实际包含数据的LiveData和仅用于观察数据的LiveData进行转换 如果对于某个结构体,只想暴露某一些字段而不是全部字段,map此时最好处理这种情况

class MainViewModel2(countReserved: Int) : ViewModel() {
    private val userLiveData = MutableLiveData<User>()

    val userName: LiveData<String> = Transformations.map(userLiveData) {
        "${it.firstName} ${it.lastName}"
    }
}

前面我们所学的所有内容都有一个前提:LiveData对象的实例都是在ViewModel中创建的。然 而在实际的项目中,不可能一直是这种理想情况,很有可能ViewModel中的某个LiveData对象 是调用另外的方法获取的

新建Repository单例类

object Repository {

    fun getUser(userId: String): LiveData<User> {
        val liveData = MutableLiveData<User>()
        liveData.value = User(userId, userId, 0)
        return liveData
    }

}

然后我们在MainViewModel中也定义一个getUser()方法,并且让它调用Repository的 getUser()方法来获取LiveData对象

class MainViewModel(countReserved: Int) : ViewModel() {

    ...

    fun getUser(userId: String): LiveData<User> {
        return Repository.getUser(userId)
    }

接下来的问题就是,在Activity中如何观察LiveData的数据变化呢?既然getUser()方法返回 的就是一个LiveData对象,那么我们可不可以直接在Activity中使用如下写法呢?

viewModel.getUser("sss").observe(this) { user->
	// 逻辑
}

请注意,这么做是完全错误的。因为每次调用getUser()方法返回的都是一个新的LiveData实例, 而上述写法会一直观察老的LiveData实例,从而根本无法观察到数据的变化。你会发现, 这种情况下的LiveData是不可观察的。

switchMap

这个时候,switchMap()方法就可以派上用场了。正如前面所说,它的使用场景非常固定: 如果ViewModel中的某个LiveData对象是调用另外的方法获取的,那么我们就可以借助 switchMap()方法,将这个LiveData对象转换成另外一个可观察的LiveData对象

修改MainViewModel中的代码

private val userIdLiveData = MutableLiveData<String>()

    val user: LiveData<User> = Transformations.switchMap(userIdLiveData) {
        Repository.getUser(it)
    }

    fun getUser(userId: String) {
        userIdLiveData.value = userId
    }

即,将获取LiveData对象的方法作为switchMap的第二个参数

switchMap()方法同样接收两个参数:第一个参数传入我们新增的userIdLiveData, switchMap()方法会对它进行观察;第二个参数是一个转换函数,注意,我们必须在这个转换函数中返回一个LiveData对象

为了让你能更清晰地理解switchMap()的用法,我们再来梳理一遍它的整体工作流程。首先, 当外部调用MainViewModel的getUser()方法来获取用户数据时,并不会发起任何请求或者 函数调用,只会将传入的userId值设置到userIdLiveData当中。一旦userIdLiveData的 数据发生变化,那么观察userIdLiveData的switchMap()方法就会执行,并且调用我们编 写的转换函数。然后在转换函数中调用Repository.getUser()方法获取真正的用户数据。 同时,switchMap()方法会将Repository.getUser()方法返回的LiveData对象转换成一 个可观察的LiveData对象,对于Activity而言,只要去观察这个LiveData对象就可以了

以上一段话结合参考下面代码

class MainViewModel(countReserved: Int) : ViewModel() {

    private val userIdLiveData = MutableLiveData<String>()

    val user: LiveData<User> = Transformations.switchMap(userIdLiveData) {
        Repository.getUser(it)
    }

    fun getUser(userId: String) {
        userIdLiveData.value = userId
    }

修改activity_main.xml文件,添加一个按钮来新增User 修改MainActivity中的代码

bind.getUserBtn.setOnClickListener {
            val userId = (0..10000).random().toString()
            viewModel.getUser(userId)
        }

        viewModel.user.observe(this) {
            bind.infoText.text = it.firstName
        }

在刚才的例子当中,我们调 用MainViewModel的getUser()方法时传入了一个userId参数,为了能够观察这个参数的数 据变化,又构建了一个userIdLiveData,然后在switchMap()方法中再去观察这个 LiveData对象就可以了。但是ViewModel中某个获取数据的方法有可能是没有参数的,这个时 候代码应该怎么写呢

class MyViewModel : ViewModel() {
    private val refreshLiveData = MutableLiveData<Any?>()
    val refreshResult = Transformations.switchMap(refreshLiveData) {
        Repository.refresh()
	// 假设Repository中已经定义了refresh()方法
    }
    fun refresh() {
        refreshLiveData.value = refreshLiveData.value
    }
}

在refresh()方法中,我们只是将refreshLiveData原有的数据取出来(默认是空),再重新设置到refreshLiveData当中, 这样就能触发一次数据变化。是的,LiveData内部不会判断即将设置的数据和原有数据是否相同,只要调用了setValue()或postValue()方法,就一定会触发数据变化事件

然后我们在Activity中观察refreshResult这个LiveData对象即可,这样只要调用了 refresh()方法,观察者的回调函数中就能够得到最新的数据。

还有一个小细节,如果在Activity处于不可见状态的时候,LiveData发生了多次数据变化, 当Activity恢复可见状态时,只有最新的那份数据才会通知给观察者,前面的数据在这种情况下相当于已经过期了,会被直接丢弃

Room

使用Room进行增删改查

Room的整体结构。它主要由Entity、Dao和Database这3部分组成,每个部分都有明确的职责,详细说明如下

  • Entity 用于定义封装实际数据的实体类,每个实体类都会在数据库中有一张对应的表,并且表中的列是根据实体类中的字段自动生成的
  • Dao Dao是数据访问对象的意思,通常会在这里对数据库的各项操作进行封装,在实际编程的时候,逻辑层就不需要和底层数据库打交道了,直接和Dao层进行交互即可。
  • Database 用于定义数据库中的关键信息,包括数据库的版本号、包含哪些实体类以及提供Dao层的访问实例

在app/build.gradle内添加依赖和插件

plugins {
    ...
    id 'kotlin-kapt'
}

dependencies {

    implementation "androidx.room:room-runtime:2.4.2"
    kapt 'androidx.room:room-compiler:2.4.2'
    ...
}

这里新增了一个kotlin-kapt插件,同时在dependencies闭包中添加了两个Room的依赖库。 由于Room会根据我们在项目中声明的注解来动态生成代码,因此这里一定要使用kapt引入

Room的编译时注解库,而启用编译时注解功能则一定要先添加kotlin-kapt插件。注意,kapt 只能在Kotlin项目中使用,如果是Java项目的话,使用annotationProcessor即可

  1. 定义实体类
@Entity
data class User(@ColumnInfo(name = "fname", defaultValue = "老八") val firstName: String, val lastName: String, val age: Int) {
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0
}

可以看到,这里我们在User的类名上使用@Entity注解,将它声明成了一个实体类,然后在 User类中添加了一个id字段,并使用@PrimaryKey注解将它设为了主键,再把 autoGenerate参数指定成true,使得主键的值是自动生成的 @ColumnInfo可以指定数据表字段名以及该字段的默认值 其中还有Entity的参数可以设置表名。主键,外键等

  1. 实现Dao
@Dao
interface UserDao {
    @Insert
   fun insertUser(user: User): Long

   @Update
   fun updateUser(newUser: User)

   @Query("select * from User")
   fun loadAllUsers(): List<User>

   @Query("select * from User where age > :age")
   fun loadUserOlderThan(age: Int): List<User>

   @Delete
   fun deleteUser(user: User)

   @Query("delete from User where lastName = :lastName")
   fun deleteUserByLastName(lastName: String): Int
}

Room是支持在编译时动态检查SQL语句语法的。也就是说,如果我们编写的SQL 语句有语法错误,编译的时候就会直接报错,而不会将错误隐藏到运行的时候才发现,也算是 大大减少了很多安全隐患吧

  1. 定义Database 这部分内容的写法是非常固定的,只需要定义好3个部分的内容: 数据库的版本号、 包含哪些实体类、 提供Dao层的访问实例。 新建一个AppDatabase.kt文件,代码如下所示
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(version = 1, entities = [User::class])
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao

    companion object {
        private var instance: AppDatabase?= null

        @Synchronized
        fun getDatabase(context: Context): AppDatabase {
            instance?.let {
                return it
            }
            return Room.databaseBuilder(
                context.applicationContext,
                AppDatabase::class.java,
                "app_database"
            ).build().apply { instance = this }
        }
    }
}

修改activity_main.xml文件,增加增删改查按钮

<Button
        android:id="@+id/addDataBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Add Data"/>
    <Button
        android:id="@+id/updateDataBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Update Data"/>
    <Button
        android:id="@+id/deleteDataBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Delete Data"/>
    <Button
        android:id="@+id/queryDataBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Query Data"/>

修改MainActivity中的逻辑

val userDao = AppDatabase.getDatabase(this).userDao()
        val user1 = User("Tom", "Brady", 40)
        val user2 = User("Tom", "Hanks", 63)
        bind.addDataBtn.setOnClickListener {
            thread {
                user1.id = userDao.insertUser(user1)
                user2.id = userDao.insertUser(user2)
            }
        }

        bind.updateDataBtn.setOnClickListener {
            thread {
                user1.age = 42
                userDao.updateUser(user1)
            }
        }

        bind.deleteDataBtn.setOnClickListener {
            thread {
                userDao.deleteUserByLastName("Hanks")
            }
        }

        bind.queryDataBtn.setOnClickListener {
            thread {
                for (user in userDao.loadAllUsers()) {
                    Log.d("MainActivity", user.toString())
                }
            }
        }

我们调用了UserDao的insertUser()方法,将这两个 User对象插入数据库中,并将insertUser()方法返回的主键id值赋值给原来的User对象。 之所以要这么做,是因为使用@Update和@Delete注解去更新和删除数据时都是基于这个id值

另外,由于数据库操作属于耗时操作,Room默认是不允许在主线程中进行数据库操作的,因此 上述代码中我们将增删改查的功能都放到了子线程中。不过为了方便测试,Room还提供了一个 更加简单的方法来操作的。

Room.databaseBuilder(context.applicationContext, AppDatabase::claa.java, "app_database")
	.allowMainThreadQueries()
	.build()

Room的数据库升级

Room在数据库升级方面设计得非常烦琐,基本上没有 比使用原生的SQLiteDatabase简单到哪儿去,每一次升级都需要手动编写升级逻辑才行 也有简单粗暴的操作

Room.Builder(context.applicationContext, AppDatabase::class.java, "app_database")
	.fallbackToDestructiveMigration()
	.build()

这样只要数据库进行了升级,Room就会将当前的数据库销毁,然后再重新创建,随之而来 的副作用就是之前数据库中的所有数据就全部丢失了(这顶多拿来测试用)

升级例子 假如要添加一个Book表 首先就是创建一个Book的实例

@Entity
data class Book(var name: String, var pages: Int) {
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0
}

增加对应的Dao接口

@Dao
interface BookDao {

    @Insert
    fun insertBook(book: Book): Long

    @Query("select * from Book")
    fun loadAllBooks(): List<Book>
}

修改AppDatabase中的代码,编写数据库升级逻辑

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

@Database(version = 2, entities = [User::class, Book::class])
abstract class AppDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao

    abstract fun bookDao(): BookDao

    companion object {

        val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL(
                    "create table Book(" +
                            "id integer primary key autoincrement not null," +
                            "name text not null," +
                            "pages integer not null)"
                )
            }

        }

        private var instance: AppDatabase? = null

        @Synchronized
        fun getDatabase(context: Context): AppDatabase {
            instance?.let {
                return it
            }
            return Room.databaseBuilder(
                context.applicationContext,
                AppDatabase::class.java,
                "app_database"
            )
                .addMigrations(MIGRATION_1_2)
                .build().apply { instance = this }
        }
    }
}

现在当我们进行任何数据库操作时,Room就会自动根据当前数据库的版本号执行这些升级逻 辑,从而让数据库始终保证是最新的版本。

添加列的话,sql改为alter对应的语句,以及Entity注解装饰的对应的对象增加对应的字段即可 eg:

MIGRATION_1_2 = object: Migration(1, 2) {...}
MIGRATION_2_3 = object: Migration(2, 3) {...}

只是addMigrations()接收不定长参数, 即

...
	.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
	.build()
	.apply { instance = this }

WorkManager

频繁的功能和API变更,让开发者就很难受了,到底该如何编写后台代码才能保证应用程序 在不同系统版本上的兼容性呢?为了解决这个问题,Google推出了WorkManager组件。 WorkManager很适合用于处理一些要求定时执行的任务,它可以根据操作系统的版本自动选择 底层是使用AlarmManager实现还是JobScheduler实现,从而降低了我们的使用成本。另外, 它还支持周期性任务、链式任务处理等功能,是一个非常强大的工具。

不过,我们还得先明确一件事情:WorkManager和Service并不相同,也没有直接的联系。 Service是Android系统的四大组件之一,它在没有被销毁的情况下是一直保持在后台运行的。 而WorkManager只是一个处理定时任务的工具,它可以保证即使在应用退出甚至手机重启的情 况下,之前注册的任务仍然将会得到执行,因此WorkManager很适合用于执行一些定期和服务 器进行交互的任务,比如周期性地同步数据,等等。

另外,使用WorkManager注册的周期性任务不能保证一定会准时执行,这并不是bug,而是系 统为了减少电量消耗,可能会将触发时间临近的几个任务放在一起执行,这样可以大幅度地减 少CPU被唤醒的次数,从而有效延长电池的使用时间。

WorkManager的基本用法

使用WorkManager需要在app/build.gradle中添加依赖

implementation 'androidx.work:work-runtime:2.7.1'

WorkManager的基本用法其实非常简单,主要分为以下3步:

  1. 定义一个后台任务,并实现具体的任务逻辑
  2. 配置该后台任务的运行条件和约束信息,并构建后台任务请求
  3. 将该后台任务请求传入WorkManger的enqueue()方法中,系统会在合适的时间运行
  • 创建后台任务
class SimpleWorker(context: Context, params: WorkerParameters):
    Worker(context, params) {
    override fun doWork(): Result {
        Log.d("SimpleWorker", "do work in SimpleWorker")
        return Result.success()
    }
}

doWork()方法不会运行在主线程当中,因此你可以放心地在这里执行耗时逻辑,不过这里简单起见只是打印了一行日志。

另外,doWork()方法要求返回一个Result对象,用于表示任务的运行结果, 成功就返回Result.success(), 失败就返回Result.failure()。 除此之外,还有一个Result.retry()方法,它其实也代表着失败,只是可以结合WorkRequest.Builder的setBackoffCriteria()方法来重新执行任务

  • 配置后台任务的运行条件和约束信息

这一步其实也是最复杂的一步,因为可配置的内容非常多,不过目前我们还只是学习 WorkManager的基本用法,因此只进行最基本的配置就可以了

val request = OneTimeWorkRequest.Builder(SimpleWorker::class.java).build()

OneTimeWorkRequest.Builder是WorkRequest.Builder的子类,用于构建单次运行的 后台任务请求。 WorkRequest.Builder还有另外一个子类PeriodicWorkRequest.Builder,可用于构建周期性运行的后台任务请求, 但是为了降低设备性能消耗,PeriodicWorkRequest.Builder构造函数中传入的运行周期间隔不能短于15分钟

val request = PeriodicWorkRequest.Builder(
	SimpleWorker::class.java,
	15,
	TimeUnit.MINUTES
).build()
  • 将构建的后台任务请求传入WorkManager的enqueue()方法中
WorkManager.getInstance(context).enqueue(request)

在activity_main.xml文件中添加Do Work按钮 修改MainActivity中的代码

bind.doWorkBtn.setOnClickListener {
            val request = OneTimeWorkRequest.Builder(SimpleWorker::class.java).build()
            WorkManager.getInstance(this).enqueue(request)
        }

使用WorkManager处理复杂的任务

WorkManager还允许我们控制许多其他方面的东西

  • 让后台任务在指的延迟时间后运行
val request1 = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
                .setInitialDelay(5, TimeUnit.MINUTES)
                .build()

这就表示我们希望让SimpleWorker这个后台任务在5分钟后运行。你可以自由选择时间的单 位,毫秒、秒、分钟、小时、天都可以。

  • 给后台任务请求添加标签
val request2 = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
                .addTag("simple")
                .build()
  • 通过标签来取消后台任务
WorkManager.getInstance(this).cancelWorkByTag("simple")
  • 通过id来取消后台任务
WorkManager.getInstance(this).cancelWorkById(request.id)

但是,使用id只能取消单个后台任务请求,而使用标签的话,则可以将同一标签名的所有后台任务请求全部取消,这个功能在逻辑复杂的场景下尤其有用

  • 一次性取消所有的后台任务
WorkManager.getInstance(this).cancelAllWork()

后台任务的doWork()方法中返回了Result.retry(),那么是可以结合setBackoffCriteria()方法来重新执行任务的,

val request = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
	...
	.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
	.build()

setBackoffCriteria()方法接收3个参数:第二个和第三个参数用于指定在多久之后重新执 行任务,时间最短不能少于10秒钟;

第一个参数则用于指定如果任务再次执行失败,下次重试的时间应该以什么样的形式延迟 第一个参数的可选值有两种,分别是LINEAR和 EXPONENTIAL,前者代表下次重试时间以线性的方式延迟,后者代表下次重试时间以指数的方式延迟。

Result.success()和Result.failure() 这两个返回值其实就是用于通知任务运行结果的,我们可以使用如下代码对后台任务的运行结果进行监听

WorkManager.getInstance(this)
                .getWorkInfoByIdLiveData(request.id)
                .observe(this) {
                    if (it.state == WorkInfo.State.SUCCEEDED) {
                        Log.d("MainActivity", "do work succeeded")
                    } else if (it.state == WorkInfo.State.FAILED) {
                        Log.d("MainActivity", "do work failed")
                    }
                }
  • WorkManager中的链式任务 假设这里定义了3个独立的后台任务:同步数据、压缩数据和上传数据。现在我们想要实现先同 步、再压缩、最后上传的功能,就可以借助链式任务来实现
val sync = ...
val compress = ...
val upload = ...
WorkManager.getInstance(this)
	.beginWith(sync)
	.then(compress)
	.then(upload)
	.enqueue()

前面所介绍的WorkManager的所有功能,在国产手机上都有可能得不到正确的运行

这是因为绝大多数的国产手机厂商在进行Android系统定制的时候会增加一个一键关闭的功能,允许用户一键杀死所有非白名单的应用程序。而被杀死的应用程序既无法接收广播, 也无法运行WorkManager的后台任务

Kotlin 使用DSL构建专有的语法结构

新建DSL.kt文件

class Dependency {
    val libraries = ArrayList<String>()

    fun implementation(lib: String) {
        libraries.add(lib)
    }
}

可以看到,dependencies函数接收一个函数类型参数,并且该参数是定义到Dependency类 中的,因此调用它的时候需要先创建一个Dependency的实例,然后再通过该实例调用函数类 型参数,这样传入的Lambda表达式就能得到执行了。最后,我们将Dependency类中保存的 依赖库集合返回。

没错,经过这样的DSL设计之后,我们就可以在项目中使用如下的语法结构了

dependencies {
        implementation("com.squareup.retrofit2:retrofit:2.6.1")
        implementation("com.squareup.retrofit2:converter-gson:2.6.1")
    }

进阶,高级技巧

全局获取Context的技巧

Android提供了一个Application类,每当应用程序启动的时候,系统就会自动将这个类进行 初始化。而我们可以定制一个自己的Application类,以便于管理程序内一些全局的状态信 息,比如全局Context

定制一个自己的Application其实并不复杂,首先需要创建一个MyApplication类继承自 Application

import android.app.Application
import android.content.Context

class MyApplication: Application() {
    companion object {
        lateinit var context: Context
    }

    override fun onCreate() {
        super.onCreate()
        context = applicationContext
    }
}

需要注意的是,将Context设置成静态变量很容易会产生内存泄漏的问题,所以这是一种有风险的做法

但是由于这里获取的不是Activity或Service中的Context,而是Application中的Context, 它全局只会存在一份实例,并且在整个应用程序的生命周期内都不会回收,因此是不存在内存 泄漏风险的。那么我们可以使用如下注解,让Android Studio忽略上述警告提示

import android.annotation.SuppressLint
import android.app.Application
import android.content.Context

class MyApplication: Application() {
    companion object {
        @SuppressLint("StaticFieldLeak")
        lateinit var context: Context
    }

    override fun onCreate() {
        super.onCreate()
        context = applicationContext
    }
}

接下来我们还需要告知系统,当程序启动的时候应该初始化MyApplication类,而不是默认的 Application类。这一步也很简单,在AndroidManifest.xml文件的标签下 进行指定就可以了

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.premeskills">

    <application
        android:name=".MyApplication"
	...

之后不管你想在项目的任何地方使用Context,只需要调用一下MyApplication.context就可以了

使用Intent传递对象

Serializable方式

将需要使用intent传输的类实现Serializable这个接口即可

class Person: Serializable {
	var name = ""
	var age = 0
}

在Activity中这么写

val person Person)()
person.name = "Tom"
perosn.age = 20
val intent = Intent(this, TargetActivity::class.java)
intent.putExtra("person_data", person)
startActivity(intent)

从TargetActivity中取出对象

val person = intent.getSerializableExtra("person_data") as Person

需要注意的是,这种传递对象的工作原理是先将一个对象序列化成可存储或可传输的状态,传 递给另外一个Activity后再将其反序列化成一个新的对象。虽然这两个对象中存储的数据完全一致,但是它们实际上是不同的对象, 这一点希望你能了解清楚

Parcelable方式

Parcelable也可以实现相同的效果, Parcelable方式的实现原理是将一个完整的对象进行分解,而分解后的每一部分都是Intent所支持的数据类型,这样就能实现传递对象的功能了

修改Person中的代码

class Person: Parcelable {
    var name = ""
    var age = 0

    override fun writeToParcel(dest: Parcel?, flags: Int) {
        dest?.apply {
            writeString(name) // 写出name
            writeInt(age) // 写出age
        }
    }

    override fun describeContents(): Int {
        return 0
    }

    companion object CREATOR: Parcelable.Creator<Person> {
        override fun createFromParcel(source: Parcel?): Person {
            val person = Person()
            person.name = source?.readString() ?: ""
            person.age = source?.readInt() ?: 0
            return person
        }

        override fun newArray(size: Int): Array<Person?> {
            return arrayOfNulls(size)
        }
    }
}

在createFromParcel()方法中,我们 要创建一个Person对象进行返回,并读取刚才写出的name和age字段。其中name和age都是 调用Parcel的readXxx()方法读取到的,注意这里读取的顺序一定要和刚才写出的顺序完全相同。

不过,这种实现方式写起来确实比较复杂,为此Kotlin给我们提供了另外一种更加简便的用法, 但前提是要传递的所有数据都必须封装在对象的主构造函数中才行 修改Person的代码

@Parcelize
class Person(val name: String, val age: Int): Parcelable

注意,当前使用这个注解需要加入插件 有些人可能会因为顺序问题不对而报错,我自己是没有碰见,但还是按照这个顺序来 在app/build.gradle下添加

plugins {
    ...
    id 'org.jetbrains.kotlin.android'
    id 'kotlin-android-extensions'
}

使用的时候

val person = intent.getParcelabelExtra("person_data") as Person

对比一下,Serializable的方式较为简单,但由于会把整个对象进行序列化,因此效率会比Parcelable方式低一些,所以在通 常情况下,还是更加推荐使用Parcelable的方式来实现Intent传递对象的功能。

定制自己的日志工具

能够自由地控制日志的打印,当程序处于开发阶段时就让日志打印出来,当程序上线之 后就把日志屏蔽掉

新建LogUtil单例类

import android.util.Log

object LogUtil {
    private const val VERBOSE = 1
    private const val DEBUG = 2
    private const val INFO = 3
    private const val WARN = 4
    private const val ERROR = 5

    private var level = VERBOSE

    fun v(tag: String, msg: String) {
        if (level <= VERBOSE) {
            Log.v(tag, msg)
        }
    }

    fun d(tag: String, msg: String) {
        if (level <= DEBUG) {
            Log.d(tag, msg)
        }
    }

    fun i(tag: String, msg: String) {
        if (level <= INFO) {
            Log.i(tag, msg)
        }
    }

    fun w(tag: String, msg: String) {
        if (level <= WARN) {
            Log.w(tag, msg)
        }
    }

    fun e(tag: String, msg: String) {
        if (level <= ERROR) {
            Log.e(tag, msg)
        }
    }
}

我们只需要通过修改level变量的值,就可以自由地控制日志的打印行为。比如让level等于 VERBOSE就可以把所有的日志都打印出来,让level等于ERROR就可以只打印程序的错误日 志。

调试Android程序

AndroidStudio正常调试就好,都用了这么久了,也调试过...

深色主题

在Android10.0系统中,Google引入了深色主题这一特性,从而让夜间模式正式成为了官方支持的功能

最简单的一种适配方式就是使用Force Dark,它是一种能让应用程序快速适配深色主题,并且 几乎不用编写额外代码的方式。Force Dark的工作原理是系统会分析浅色主题应用下的每一层 View,并且在这些View绘制到屏幕之前,自动将它们的颜色转换成更加适合深色主题的颜色。 注意,只有原本使用浅色主题的应用才能使用这种方式,如果你的应用原本使用的就是深色主 题,Force Dark将不会起作用。

此处使用MaterialTest项目来测试 启用Force Dark功能需要 借助android:forceDarkAllowed属性,不过这个属性是从API 29,也就是Android 10.0 系统开始才有的,之前的系统无法指定这个属性。因此,我们得进行一些系统差异型编程才行。

在res目录下新建values-v29目录,在该目录下创建styles.xml文件 加入如下代码

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <item name="colorPrimary">@color/purple_500</item>
        <item name="colorPrimaryDark">@color/purple_700</item>
        <item name="colorAccent">@color/purple_200</item>
        <item name="android:forceDarkAllowed">true</item>
    </style>
</resources>

除了android:forceDarkAllowed属性之外,其他的内容都是从之前的styles.xml文件中复 制过来的。这里给AppTheme主题增加了android:forceDarkAllowed属性并设置为 true,说明现在我们是允许系统使用Force Dark将应用强制转换成深色主题的。另外, values-v29目录是只有Android 10.0及以上的系统才会去读取的,因此这是一种系统差异型编 程的实现方式。

在res下新建values-night目录天际colors.xml文件,内部颜色内容替换为深色,其他标签名和values目录下的colors.xml文件相同

这样的话,在普通情况下,系统仍然会读取values/colors.xml文件中的颜色值,而一旦用户开 启了深色主题,系统就会去读取values-night/colors.xml文件中的颜色值了

指定控件颜色时应该减少硬编码,应该根据当前主题自动切换颜色

<TextView
	android:layout_width="wrap_content"
	android:layout_height="wrap_content"
	android:layout_gravity="center"
	android:text="Hello world"
	android:textSize="40sp"
	android:textColor="?android:attr/textColorPrimary" />

使用如下代码获取当前系统是否为深色主题

fun isDark(context: Context): Boolean {
    val flag = context.resources.configuration.uiMode and
            Configuration.UI_MODE_NIGHT_MASK
    return flag == Configuration.UI_MODE_NIGHT_YES
}

另外,由于Kotlin取消了按位运算符的写法,改成了使用英文关键字,因此上述代码中的 and关键字其实就对应了Java中的&运算符,而Kotlin中的or关键字对应了Java中的|运算符xor关键字对应了Java中的^运算符

Kotlin: Java代码与Kotlin代码之间的转换

  • Java代码转换成Kotlin代码可以使用AndroidStudio直接转化,加上把转换不好的地方自己修改

  • Kotlin代码无法直接转换成Java代码,但是可以先将Kotlin代码转换成Kotlin字节码,然后再通过反编译的方式将它还原成 Java代码。这种反编译出来的代码可能无法像正常编写的Java代码那样直接运行,但是非常有利于帮助我们理解诸多Kotlin特性背后的实现原理

后记

之后的两章则是实战内容了,一个是开发一个天气APP, 一个是开发一个权限管理库 这次书本(800页)学习一共耗时14天([中间因为连续看书很累,休息了一天,看了看go语言底层]),基本就是一章一天,总体来说没有很难的东西,所以还是加油吧,未来还需要学习自定义view,等高级内容,以及整体架构一个app。

继续需系Android自定义view等高级功能,以及app架构方面知识,顺带学习前端开发知识,以及go底层原理

评论 (...)

加载中...