Android基础经验汇总

2021-12-07 fishedee 前端

0 概述

Android的入门书籍了,没啥好说的。经过几年发展,Kotlin已经成为了Android的官方支持语言了。

本经验汇总,主要参考第一行Android代码(第3版),和疯狂Android讲义(Kotlin版)。总体来说,《第一行Android代码》讲解得更好一点,细致而且贴近新版开发(Android 10)。

1 安装与入门

1.1 IDE

这里下载Android Studio,注意选择Intel芯片,还是Apple的ARM芯片。

注意买个梯子,因为很多SDK都需要从外面下载回来。

1.2 空项目

打开Android Studio,点击左上角的File和New就能创建新项目了,没啥好说的

1.3 目录结构

.
├── app
│   ├── build.gradle
│   ├── libs
│   ├── proguard-rules.pro
│   └── src
│       ├── androidTest
│       ├── main
│       └── test
├── build.gradle
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── local.properties
└── settings.gradle

8 directories, 10 files

默认生成的项目结构如上。

IDE提示各个文件的意义

1.3.1 local.properties

## This file is automatically generated by Android Studio.
# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
#
# This file should *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
sdk.dir=/Users/XXX/Library/Android/sdk

local.properties是SDK的位置,很少改动

1.3.2 build.gradle

// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
    repositories {
        google()
        mavenCentral()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:7.0.3"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.20"

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

build.gradle是项目级别的gradle脚本文件,相当于Maven里面的pom.xml

plugins {
    id 'com.android.application'
    id 'kotlin-android'
}

android {
    compileSdk 31

    defaultConfig {
        applicationId "com.example.myapplication"
        minSdk 21
        targetSdk 31
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {
    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.3.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

在app/build.gradle,描述的是模块级别的gradle脚本文件。

1.3.3 gradle.properties

# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official

描述的是gradle脚本文件的通用变量配置,这个变量会影响当前项目所有子模块的gradle脚本文件。

## For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
#
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx1024m -XX:MaxPermSize=256m
# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
#
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
#Tue Dec 07 16:14:18 CST 2021
systemProp.http.proxyHost=127.0.0.1
systemProp.https.proxyHost=127.0.0.1
systemProp.https.proxyPort=7890
systemProp.http.proxyPort=7890

对应的我们有gradle的全局变量配置,在/Users/xxxx/.gradle/gradle.properties文件中,一般用来配置代理。

1.3.4 settings.gradle

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        maven { url "https://frontjs-static.pgyer.com/dist/sdk/pgyersdk" }  //主力仓库
        maven { url "https://raw.githubusercontent.com/Pgyer/analytics/master" }  //备用仓库(主力仓库下载不下来使用)


        google()
        mavenCentral()
        jcenter() // Warning: this repository is going to shut down soon
    }
}
rootProject.name = "My Application"
include ':app'

settings.gradle是gradle的配置文件,主要描述的有使用哪些仓库,rootProject的名称是什么(rootProject),包含什么子模块(:include)。

settings.gradle也是配置依赖镜像的地方,相当于原来的allprojects下的repositories配置。

1.3.5 gradlew,gradlew.bat和gradle/wrapper

这里

gradle是实际干活的可执行文件,但是不同的gradle配置使用不同gradle插件,需要不同版本的gradle。因此,这需要每个Android项目都需要一个不同版本的gradle,而不是像Maven一样全局一个Maven可执行文件。

为了抹平不同Android项目下对于gradle的调用,AndroidStudio自动生成graddle wrapper。gradlew和gradlew.bat是不同平台统一的gradle wrapper可执行文件。它会到gradle/wrapper中,根据配置文件自动下载不同版本的gradle来运行实际的命令。

#Tue Dec 07 16:13:40 CST 2021
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

gradle/wrapper/gradle-wrapper.properties的配置文件,描述的是从哪里下载什么版本的gradle

JAVA_HOME="/Applications/Android Studio.app/Contents/jre/Contents/Home" ./gradlew

命令行执行gradlew的方式,指定JRE版本为Android Studio的内置版本。

1.3.6 proguard-rules.pro

# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
#   http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
#   public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

在app/proguard-rules.pro文件中,描述的是该模块如何混淆文件。

1.4 build.gradle脚本

1.4.1 典型配置

plugins {
    id 'com.android.application'
    id 'kotlin-android'
}

android {
    compileSdk 31

    defaultConfig {
        applicationId "com.example.myapplication"
        minSdk 21
        targetSdk 31
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {
    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.3.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

这是IDE生成的典型配置

1.4.2 sdkVersion配置

这里

  • compileSdkVersion,编译时用什么SDK版本来编译
  • minSdkVersion,运行时,App仅支持什么SDK版本以上的系统。
  • targetSdkVersion,运行时,App仅在targetSdkVersion版本以下完整测试,设备的操作系统需要根据targetSdkVersion进行兼容。
targetSdkVersion 情况 含义
targetSdkVersion <平台的API级别 由系统保证兼容性,举个栗子:targetSdkVersion==23(6.0),当前设备Android版本7.0,运行程序时用6.0那一套接口。
targetSdkVersion = 平台的API级别 不启用兼容性。
targetSdkVersion > 平台的API级别 由开发者自己保证兼容性,举个栗子:targetSdkVersion==23(6.0),当前设备Android版本是 5.0 ,运行程序 用的是5.0那套接口。设备根本到不了6.0

举个例子

在 Android 4.4 (API 19)以后,AlarmManager 的 set() 和 setRepeat() 这两个 API 的行为发生了变化。在 Android 4.4 以前,这两个 API 设置的都是精确的时间,系统能保证在 API 设置的时间点上唤醒 Alarm。因为省电原因 Android 4.4 系统实现了 AlarmManager 的对齐唤醒,这两个 API 设置唤醒的时间,系统都对待成不精确的时间,系统只能保证在你设置的时间点之后某个时间唤醒。虽然api的名字没有改变,但是功能结果已经发生改变,

我们设置targetSdkVersion为16,系统通过targetSdkVersion来保证Android的向前兼容性,在Android4.4 (API 19)之后的设备上,系统会判断你的targetSdkVersion是否小于19,如果小于的话,那就按照19之前的api方法,如果大于等于19,那么就按照之后的api方法来走,保证了程序运行的一致性。也就是向前兼容性。

系统会根据你的targetSdkVersion对同一个接口使用不同的执行方式,以保证兼容性。

1.5 单独的kotlin文件

1.5.1 build.gradle配置

plugins {
    id 'com.android.application'
    id 'kotlin-android'
}

android {
    compileSdk 30

    defaultConfig {
        applicationId "com.example.myapplication"
        minSdk 21
        targetSdk 30
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {

    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.3.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

注意先将compileSdk和targetSdk从31改到30,否则会报出:app:processDebugAndroidTestManifest‘ FAILED android:exported <activity>错误

这里

1.5.2 单文件启动

新建一个Kotlin file

package com.example.myapplication

fun main(){
    println("uu");
}

输入以上代码

点击绿色小箭头就能启动了

1.6 仓库与镜像

仓库地址

// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
    repositories {
        //版阿里云仓库
        maven {url 'https://maven.aliyun.com/repository/google'}
        maven {url 'https://maven.aliyun.com/repository/central'}
        maven {url 'https://maven.aliyun.com/repository/gradle-plugin'}

        google()
        mavenCentral()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:7.0.3"
        classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.0'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

使用阿里云镜像,官方地址在这里

1.7 Android Studio的Gralde任务

在Android Studio的View,Tool Window,Gradle中能显式以上的内容。在这里可以看到Gradle的依赖以及任务。双击对应的Task就能启动Gradle任务了。

新版的Android Studio可能看不到Gradle的任务,需要先这样做。先在Android Studio中Setting中,将Do not build Gradle task list 的选项去掉就可以了。

2 Activity

Activity是安卓的基础模块了,相当于桌面中窗口的概念。

2.1 UI与菜单

代码在这里

2.1.1 定义layout

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!--@+id/button1,是定义一个ID,而不是使用一个ID,该ID的名称为button1-->
    <Button
        android:id="@+id/button1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="点我"/>

    <Button
        android:id="@+id/button2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="点我2"/>

</LinearLayout>

首先,在res/layout/activity_main.xml定义以上的文件,那么Android Studio就会定义一个layout,id为activity_main

另外,由于Button中的id使用@+id的语法,所以,也会自动定义一个新的ID,名称为button1,和button2

2.1.2 定义menu

<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/add_item"
        android:title="Add"/>
    <item
        android:id="@+id/remove_item"
        android:title="Remove"/>
</menu>

在res/layout/menu中定义main.xml,描述了一个菜单资源。

2.1.3 AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.myapplication">

    <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/Theme.MyApplication">
        <!--android:label是标题栏的内容-->
        <activity
            android:name=".MainActivity"
            android:label="标题栏"
            android:exported="true">
            <intent-filter>
                <!--action为MAIN是指定入口的Activity-->
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

定义Android的配置文件,每个Activity都需要先在这里注册。注意,入口的Activity的exported属性,和intent-filter属性都需要配置好,才能正常运行。activity的顶部标题栏为android:label来配置的。

2.1.4 Activity

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.Button
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        var button = findViewById<Button>(R.id.button1);
        button.setOnClickListener {
            Toast.makeText(this,"You clicked button1",Toast.LENGTH_SHORT).show();
        }
        //kotlin-android-extensions,使用这个插件自动生成出来的,不需要再用findViewById
        button2.setOnClickListener {
            Toast.makeText(this,"You clicked button2",Toast.LENGTH_SHORT).show();
        }
    }

    override fun onCreateOptionsMenu(menu: Menu?): Boolean {
        //将menu xml实例化到menu实体上
        menuInflater.inflate(R.menu.main,menu)
        return true
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        //menu的项点击时的处理
        when(item.itemId){
            R.id.add_item->Toast.makeText(this,"You clicked add_item",Toast.LENGTH_SHORT).show()
            R.id.remove_item->Toast.makeText(this,"You clicked remove_item",Toast.LENGTH_SHORT).show()
        }
        return true
    }
}

最后我们写上MainActivity的代码就可以了,没啥好说的。一般情况下,我们需要先使用findViewById获取View的实例,再进行绑定事件操作,就像button1的处理。

2.1.5 kotlin-android-extensions

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-android-extensions'
}

android {
    compileSdk 31

    defaultConfig {
        applicationId "com.example.myapplication"
        minSdk 21
        targetSdk 31
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {

    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.3.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

findViewById的问题在于,繁琐,而且每次需要类型转换,不安全。Kotlin中有省事的绑定方式,称为kotlin-android-extensions。首先在build.gradle中的plugin,添加上 ’kotlin-android-extensions’这一句。

然后在View->Gradle窗口中,选择右键,点击Reload Gradle Project,将Gradle配置同步上就可以了

import kotlinx.android.synthetic.main.activity_main.*

//kotlin-android-extensions,使用这个插件自动生成出来的,不需要再用findViewById
button2.setOnClickListener {
    Toast.makeText(this,"You clicked button2",Toast.LENGTH_SHORT).show();
}

那么用button2的时候,直接使用button2就可以了,android studio会自动import对应的类,这个时候的button2是强类型,安全可靠的,而且也不再需要使用findViewById了,省事。

2.1.6 反编译kotlin

这个kotlin-android-extensions为啥这么屌,我们来看看原理是什么。首先,点开Activity的代码文件,然后选择Tools->Kotlin->Show Kotlin Bytecode。

然后选择,左上角的Decompile按钮

我们就能看到MainActivity的Java版本代码了。kotlin-android-extensions其实就是做了简单的事情,调用了本地的自动生成的_$_findCachedViewById方法而已,而且根据XML的配置,自动进行了类型转换。这件事其实没啥神奇的。

2.2 跳转

代码在这里

2.2.1 显式跳转

package com.example.myapplication.explicit

import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.myapplication.R
import kotlinx.android.synthetic.main.activity_main.*

class ExplicitFirstActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_explicit_first)
        button1.setOnClickListener {
            //通过直接指定Activity类名的方式来启动Intent
            var intent = Intent(this,ExplicitSecondActivity::class.java);
            startActivity(intent)
        }
    }
}

显式跳转也比较简单,直接传入指定的Activity的class就可以了。注意,转入的java的class,所以看清楚写法。

2.2.2 显式跳转,带数据和结果

package com.example.myapplication.explicit_result

import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import com.example.myapplication.R
import com.example.myapplication.explicit.ExplicitSecondActivity
import kotlinx.android.synthetic.main.activity_main.*

class ExplicitResultFirstActivity : AppCompatActivity() {
    val requestCode = 101;
    val logName = ExplicitResultFirstActivity::class.simpleName;
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_explicit_result_first)
        button1.setOnClickListener {
            //通过直接指定Activity类名的方式来启动Intent
            var intent = Intent(this, ExplicitResultSecondActivity::class.java);
            intent.putExtra("name","fish");
            startActivityForResult(intent,requestCode)
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if( requestCode == this.requestCode ){
            Log.d(logName,"requestCode is ${requestCode} resultCode is ${resultCode}");
            if( resultCode == RESULT_OK){
                var returnedData = data?.getStringExtra("age");
                Log.d(logName,"result is ${returnedData}");
            }
        }
    }
}
  • 显式跳转带数据,就是在Intent创建以后,加入putExtra就可以了
  • 显式跳转需要返回结果时候,需要用startActivityForResult,而不是startActivity,并且传入requestCode。最后,在拿到requestCode以后,在onActivityResult判断returnCode就可以了。
package com.example.myapplication.explicit_result

import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import com.example.myapplication.R
import kotlinx.android.synthetic.main.activity_explicit_result_second.*


class ExplicitResultSecondActivity : AppCompatActivity() {
    val logName = ExplicitResultSecondActivity::class.simpleName;


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

        //取出当前的intent,获取参数值
        var inputData = intent.getStringExtra("name");
        Log.d(logName,"input ${inputData}")

        button1.setOnClickListener {
            var intent = Intent()
            //设置返回值
            intent.putExtra("age","789")

            //RESULT_OK的值为-1
            //默认按下返回键退出的时候,返回值为RESULT_CANCELED,值为0
            setResult(RESULT_OK,intent)

            //退出当前的Activity
            finish()
        }
    }
}

在被跳转的activity中,拿出当前的intent,就能拿到extra内容了。返回结果的时候,需要使用setResult再finish的方式结束。

2.2.3 隐式跳转

package com.example.myapplication.implicit

import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.myapplication.R
import kotlinx.android.synthetic.main.activity_implicit_main.*

class ImplicitMainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_implicit_main)
        button_web.setOnClickListener {
            //隐式intent的方式,先传入action
            var intent = Intent(Intent.ACTION_VIEW)
            //再填入category,category为default的时候可以不传入
            //intent.addCategory("android.intent.category.DEFAULT")

            //最后传入data,这个看不同的action再决定要不要传入
            intent.data = Uri.parse("https://www.baidu.com")

            //本地的WebActivity,与内置的浏览器都能响应这个Intent
            startActivity(intent)
        }

        button_tel.setOnClickListener {
            var intent = Intent(Intent.ACTION_DIAL)
            intent.data = Uri.parse("tel:10086")
            startActivity(intent)
        }

        button_category.setOnClickListener {
            var intent = Intent("com.fishedee.ACTION_START")
            intent.addCategory("com.fishedee.MY_CATEGORY")
            startActivity(intent)
        }
    }
}

隐式跳转,有两种用法:

  • 隐式跳转其他app的activity,那么就在intent填入action,然后用setData,或者addCategory来扩展需要指定的具体内容。看ACTION_VIEW和ACTION_DIAL的用法。
  • 隐式跳转自身app的其他activity。那么也是用intent先填入自定义的action,用addCategory填入自定义的category即可。
<activity
    android:name=".implicit.ImplicitCategoryActivity"
    android:exported="false">
    <intent-filter>
        <action android:name="com.fishedee.ACTION_START" />
        <!-- 注意即使有了自定义的MY_CATEGORY,也必须加上DEFAULT的category,否则启动不了 -->
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="com.fishedee.MY_CATEGORY" />
    </intent-filter>
</activity>
<activity
    android:name=".implicit.ImplicitWebActivity"
    android:exported="true">
    <intent-filter tools:ignore="AppLinkUrlError">
        <action android:name="android.intent.action.VIEW" />

        <category android:name="android.intent.category.DEFAULT" />

        <data android:scheme="https" />
    </intent-filter>
</activity>

对于能被其他app唤起的activity,需要将exported设置为true。注意,任何时候,对于能被隐式跳转的activity,都需要加上android.intent.category.DEFAULT的category。

2.2.4 最佳实践

2.2.4.1 维护activity栈

package com.example.myapplication.best

import android.app.Activity

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

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

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

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

先建立一个activity栈

package com.example.myapplication.best

import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity

//用来记录所有的Activity,方便记录Activity栈,以及一键退出程序
open class BestBaseActivity :AppCompatActivity(){
    override fun onCreate(savedInstanceState:Bundle?){
        super.onCreate(savedInstanceState)
        Log.d("base create",this::class.java.simpleName)

        ActivityCollector.add(this)
    }

    override fun onDestroy() {
        super.onDestroy()

        Log.d("base destroy",this::class.java.simpleName)


        ActivityCollector.remove(this)
    }
}

建立一个可以被其他Activity继承的基础Activity

package com.example.myapplication.best

import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import com.example.myapplication.R
import kotlinx.android.synthetic.main.activity_best.*

class BestActivity : BestBaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_best)

        var name = intent.getStringExtra("name")
        Log.d("BestActivity","input name = ${name}")

        button1.setOnClickListener {
            BestActivity.actionStart(this,"mm")
        }
        button2.setOnClickListener {
            ActivityCollector.finishAll()
        }
    }
}

那么,当我们希望可以一键关闭所有Activity的时候,直接用ActivityCollector的finishAll就可以了。

2.2.4.2 强类型跳转activity

package com.example.myapplication.best

import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import com.example.myapplication.R
import kotlinx.android.synthetic.main.activity_best.*

class BestActivity : BestBaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_best)

        var name = intent.getStringExtra("name")
        Log.d("BestActivity","input name = ${name}")

        button1.setOnClickListener {
            BestActivity.actionStart(this,"mm")
        }
        button2.setOnClickListener {
            ActivityCollector.finishAll()
        }
    }

    //伴生对象,相当于静态方法
    //使用伴生对象来启动其他的Activity
    companion object{
        fun actionStart(content:Context,name:String){
            var intent = Intent(content,BestActivity::class.java)
            intent.putExtra("name",name)
            content.startActivity(intent)
        }
    }
}

强类型跳转,用伴生对象的方法生成一个actionStart就可以了。这样的话,传入参数,与取出参数都在同一个代码文件里面,更为可靠安全。

2.3 生命周期

2.3.1 概述

首先,放出大图,展示activity的生命周期,一般来说,activity只会停留在如下的几个状态:

  • onCreate,当前activity在Activity栈中。对应的离开时间为onDestroy。
  • onStart,当前activity在Activity栈中,且用户可见。对应的离开时间为onStop。
  • onResume,当前activity在Activity栈中,且用户可见,且在栈顶。对应的离开时间为onPause。

2.3.2 切换到下一个普通的Activity

如果Activity的A跳转到Activity的B,会发生什么情况?

  • A: onPause->onStop
  • B: onCreate->onStart->onResume

返回的时候:

  • B: onPause->onStop->onDestroy
  • A: onRestart->onStart->onResume

2.3.3 切换到下一个Dialog的Activity

代码在这里

如果Activity的A跳转到Dialog主题Activity的B,会发生什么情况?

  • A: onPause
  • B: onCreate->onStart->onResume

注意,相比2.3.2,少了一个onStop触发。因为出现了一个Dialog的Activity的时候,底层的Activity A对用户来说依然是可见的,只是不在栈顶而已。

返回的时候:

  • B: onPause->onStop->onDestroy
  • A: onResume

2.3.4 旋转屏幕

如果用户正在看Activity的A,然后旋转屏幕,会发生什么情况?

如果该activity没有添加configChanges属性

  • A: onPause->onStop->onDestroy->onCreate->onStart->onResume,完全就是一个重建activity的过程

如果该activity添加configChanges属性

<activity android:name=".Test"
 android:configChanges="orientation|keyboard">
</activity>

那么A的activity的生命周期没有触发,只会触发onConfigurationChanged属性

2.3.5 其他情况

  • 最小化Activity,相当于从A跳转到B而已。没啥好说的

2.4 运行模式

<activity
    android:name=".MainActivity"
    android:label="标题栏"
    android:launchMode="standard">
</activity>

每个activity在Manifest可以设置launchMode,根据配置分别为:

  • standard,每次激活Activity时都会创建Activity,并放入任务栈中。
  • singleTop,如果在任务的栈顶正好存在该Activity的实例, 就重用该实例,否者就会创建新的实例并放入栈顶(即使栈中已经存在该Activity实例,只要不在栈顶,都会创建实例)。
  • singleTask,如果在栈中已经有该Activity的实例,就重用该实例(会调用实例的onNewIntent())。重用时,会让该实例回到栈顶,因此在它上面的其他Activity将会被移除栈。如果栈中不存在该实例,将会创建新的实例放入栈中。
  • singleInstanse,在一个新栈中创建该Activity实例,并让多个应用共享改栈中的该Activity实例。一旦改模式的Activity的实例存在于某个栈中,任何应用再激活改Activity时都会重用该栈中的实例,其效果相当于多个应用程序共享一个应用,不管谁激活该Activity都会进入同一个应用中。

3 UI

Android的UI还是比较简单的,虽然没有React那么顺手,但总体的性能较好,设计也合理

3.1 基础控件

代码在这里

3.1.1 TextView

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:divider="@drawable/divider"
    android:showDividers="middle"
    android:padding="10dp"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        android:text="文字"/>
    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        android:gravity="center"
        android:text="文字,gravity:center"/>
    <TextView
        android:layout_height="40dp"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        android:gravity="center_vertical|end"
        android:text="文字,gravity:center_vertical|end"/>
    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        android:textColor="#00FF00"
        android:text="文字,textColor"/>
    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        android:textSize="20sp"
        android:text="文字,textSize"/>
</LinearLayout>

TextView的几个属性:

  • text,文字
  • textColor,颜色
  • textSize,大小,一般用sp作为单位,因为需要跟随用户在系统配置而变化
  • gravity,文字在整个TextView里面的分布

效果如上

3.1.2 Button

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:divider="@drawable/divider"
    android:showDividers="middle"
    android:padding="10dp"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <!--默认全部text为大写,需要设置textAllCaps为false-->
    <Button
        android:id="@+id/button1"
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:text="Click Me"/>
    <Button
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:textAllCaps="false"
        android:text="Click Me"/>
</LinearLayout>

Button的属性:

  • textAllCaps,默认为全部大写
package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import kotlinx.android.synthetic.main.activity_button.*

class ButtonActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_button)
        button1.setOnClickListener {
            AlertDialog.Builder(this).apply {
                setTitle("This is dialog")
                setMessage("Something important")
                //不能点击灰白处关闭
                setCancelable(false)
                //闭包的两个参数
                setPositiveButton("确认"){dialog,which->}
                setNegativeButton("取消"){dialog,which->}
            }.show()
        }
    }
}

效果如上

3.1.3 EditText

3.1.3.1 基础输入

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:divider="@drawable/divider"
    android:showDividers="middle"
    android:padding="10dp"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <EditText
        android:id="@+id/textInput"
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        android:text="文字"/>
    <EditText
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        android:hint="请输入文字"
        android:text="文字,hint"/>
    <EditText
        android:layout_height="40dp"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        android:maxLines="2"
        android:text="文字,maxLines=2"/>
    <EditText
        android:layout_height="40dp"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        android:singleLine="true"
        android:text="文字,singleLine=true"/>
    <EditText
        android:layout_height="40dp"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        android:inputType="textPassword"
        android:text="文字,inputType=textPassword"/>
</LinearLayout>

EditText的几个属性:

  • text,文字
  • hint,相当于Input里面的placeholder
  • maxLines,高度最多多少行,超出行数以后会有滚动条
  • inputType,输入的数据类型
  • singleLine,singleLine字段已经不再推荐使用了。对于单行文本,官方推荐都填写maxLines=1和inputType的属性,这样能达到singleLine一样的效果。
package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import androidx.core.widget.doOnTextChanged
import kotlinx.android.synthetic.main.activity_edit_text.*

class EditTextActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_edit_text)
        textInput.doOnTextChanged { text, start, before, count ->
            Log.d("editText text","${text} start:${start} before:${before} count:${count}")
        }
    }
}

注意EditText的doOnTextChanged回调

效果如上

3.1.3.2 软键盘确认输入

package com.example.myapplication

import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.widget.AppCompatEditText

class MyEditText(ctx:Context,attrs:AttributeSet) :AppCompatEditText(ctx,attrs){

    init{
        this.setOnEditorActionListener { v, actionId, event ->
            if( actionId == EditorInfo.IME_ACTION_SEARCH){
                Toast.makeText(this.context,"键盘的搜索按键",Toast.LENGTH_SHORT).show()
                //需要手动关闭软键盘
                hiddenKeyboard()
                true
            }else{
                false
            }
        }
    }

    private fun showKeyboard(){
        this.requestFocus()
        val imm = this.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        imm.showSoftInput(this,InputMethodManager.SHOW_IMPLICIT)
    }

    private fun hiddenKeyboard(){
        val activity = this.context as Activity
        val imm = this.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        val currentFocus = activity.currentFocus
        if( imm.isActive && currentFocus != null ){
            val windowToken = currentFocus.windowToken
            if( windowToken != null ){
                imm.hideSoftInputFromWindow(windowToken,InputMethodManager.HIDE_NOT_ALWAYS)
            }
        }
    }
}

我们需要键盘确认的时候,回调给外部方,并自动收起软键盘

<com.example.myapplication.MyEditText
    android:layout_width="match_parent"
    android:layout_height="40dp"
    android:background="@drawable/border"
    android:imeOptions="actionSearch"
    android:inputType="text"
    android:maxLines="1"
    android:text="imeOptions,需要配置inputType,最好也配置maxLines才能生效"/>

布局文件

效果如上

3.1.3.3 扫码枪输入

package com.example.myapplication

import android.app.Activity
import android.content.Context
import android.text.Editable
import android.text.TextWatcher
import android.util.AttributeSet
import android.util.Log
import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.appcompat.widget.AppCompatEditText

class BarCodeEditText(ctx:Context,attrs:AttributeSet) :AppCompatEditText(ctx,attrs){
    private var mBeginning = System.nanoTime()
    private var barCodeListener:((String)->Unit)? = null

    fun setOnBarCodeListener(listener:(String)->Unit){
        this.barCodeListener = listener
    }

    init{
        addTextChangedListener(object :TextWatcher{
            override fun afterTextChanged(s: Editable?) {
            }
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
                if( (s == null || s.isEmpty()) && start == 0 ){
                    mBeginning = System.nanoTime()
                }
            }

            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
            }
        })
        setOnEditorActionListener { v, actionId, event ->
            if( actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_ACTION_SEARCH){
                val inputText = this.text.toString()
                barCodeListener?.invoke(inputText)
                setText("")
                hiddenKeyboard()
                true
            }else{
                false
            }

        }
    }

    override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
        if( keyCode == KeyEvent.KEYCODE_ENTER){
            return true
        }
        return super.onKeyDown(keyCode, event)
    }

    override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
        if( keyCode == KeyEvent.KEYCODE_ENTER ){
            val inputText = this.text.toString().trim()
            if( inputText.isNotBlank() ){
                barCodeListener?.invoke(inputText)
            }
            this.setText("")
            this.hiddenKeyboard()
            return true
        }
        return super.onKeyUp(keyCode, event)
    }

    private fun hiddenKeyboard(){
        val activity = this.context as Activity
        val imm = this.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        val currentFocus = activity.currentFocus
        if( imm.isActive && currentFocus != null ){
            val windowToken = currentFocus.windowToken
            if( windowToken != null ){
                imm.hideSoftInputFromWindow(windowToken,InputMethodManager.HIDE_NOT_ALWAYS)
            }
        }
    }
}

扫码枪接收到Enter字符,或者,接收到键盘的Done按钮的时候,触发二维码通知,并清除EditText的输入。注意,我们覆写onKeyDown和onKeyUp事件,是为了避免EditText在遇到Enter输入以后,会自动跳到下一个焦点。特别要注意的是,不要过分相信扫码枪的数据,扫码枪里面的数据可能会有换行字符,前后空格字符,甚至是一个空字符串,需要做好输入校正的操作。

<com.example.myapplication.BarCodeEditText
    android:layout_width="match_parent"
    android:layout_height="40dp"
    android:background="@drawable/border"
    android:imeOptions="actionSearch"
    android:inputType="text"
    android:maxLines="1"
    android:text=""/>

布局文件

效果如上

3.1.3.4 避免进入页面打开软键盘

<activity
    android:name=".OrderDetailActivity"
    android:windowSoftInputMode="adjustUnspecified|adjustResize|stateHidden"
    android:exported="false" />

禁止页面跳转弹出输入法键盘设置

3.1.4 ImageView

3.1.4.1 src

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:divider="@drawable/divider"
    android:showDividers="middle"
    android:padding="10dp"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <ImageView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@drawable/border"
        android:src="@drawable/sample"/>
</LinearLayout>

ImageView的属性:

  • src,数据来源,没啥好说的

效果如上

3.1.4.2 scaleType

<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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=".ImageViewActivity2">
    <LinearLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="10dp">
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="120dp"
            android:background="@drawable/border"
            android:scaleType="fitXY"
            android:src="@drawable/sample"/>
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="120dp"
            android:background="@drawable/border"
            android:scaleType="fitStart"
            android:src="@drawable/sample"/>
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="120dp"
            android:background="@drawable/border"
            android:scaleType="fitCenter"
            android:src="@drawable/sample"/>
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="120dp"
            android:background="@drawable/border"
            android:scaleType="fitEnd"
            android:src="@drawable/sample"/>
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="120dp"
            android:background="@drawable/border"
            android:scaleType="center"
            android:src="@drawable/sample"/>
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="120dp"
            android:background="@drawable/border"
            android:scaleType="centerCrop"
            android:src="@drawable/sample"/>
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="120dp"
            android:background="@drawable/border"
            android:scaleType="centerInside"
            android:src="@drawable/sample"/>
    </LinearLayout>
</ScrollView>

我们测试ImageView的各种scaleType

效果如上。

当ImageView的宽高比与Image的宽高比不一致的时候,怎么显示出来,这就是ScaleType要做的事情了。

  • fitXY,强行缩放图片,使得图片宽高比与框一致,但是这样做会变形。特点是无余位,变形,不丢失信息。
  • fitStart,fitCenter,fitEnd。图片最长边与框对齐。特点是可能余位,无变形,不丢失信息。
  • centerCrop。图片最短边与框对齐。特点是无余位,无变形,总是丢失信息。

最后,有些情况下,图片很小,但是框很大,我们就不希望图片还缩放了。我们有:

  • center。框较大时不缩放,框较小时裁剪图片中间信息。特点是可能余位,无变形,不丢失信息。
  • centerInside。框较大时不缩放,框较小时等比例缩放图片。特点是可能余位,无变形,不丢失信息。

无余位,无变形,不丢失信息,三个特征只能选两个。默认情况,ImageView的ScaleType是fitCenter。

附上另外一种ScaleType方式

3.1.4.3 adjustViewBounds

当我们希望框的宽高比总是与图片一致的时候,就用这个属性。先用width或者height固定框的一边,然后用adjustViewBounds来让ImageView自动控制另外一边。

<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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">
    <LinearLayout
        android:divider="@drawable/divider"
        android:showDividers="middle"
        android:padding="10dp"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@drawable/border"
            android:src="@drawable/sample"/>
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@drawable/border"
            android:adjustViewBounds="true"
            android:src="@drawable/sample"/>
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@drawable/border"
            android:src="@drawable/flower"/>
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@drawable/border"
            android:adjustViewBounds="true"
            android:src="@drawable/flower"/>
    </LinearLayout>
</ScrollView>

布局如上

效果如上

3.1.5 ProgressBar

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:divider="@drawable/divider"
    android:showDividers="middle"
    android:padding="10dp"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <ProgressBar
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:background="@drawable/border"/>
    <!--visibility为gone,看不到,不会占位-->
    <ProgressBar
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        style="?android:attr/progressBarStyleHorizontal"
        android:visibility="gone"/>
    <ProgressBar
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        style="?android:attr/progressBarStyleHorizontal"
        android:max="100"
        android:progress="45"/>
    <!--visibility为invisible,看不到,且依然会占位-->
    <ProgressBar
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        style="?android:attr/progressBarStyleHorizontal"
        android:visibility="invisible"/>
    <ProgressBar
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:background="@drawable/border"
        style="?android:attr/progressBarStyleHorizontal"
        android:max="100"
        android:progress="90"/>
</LinearLayout>

ProgressBar的几个属性:

  • style,默认会圆形的,通过设置style,可以设置为横条进度,或者竖条进度
  • max,最大的进度值
  • progress,当前的进度值

效果如上

3.1.6 通用属性

通用属性有:

  • layout_width,宽度,有wrap_content,和match_parent两种,一般用dp作为单位。
  • layout_height,高度,有wrap_content,和match_parent两种,一般用dp作为单位。
  • margin,边框
  • padding,填充
  • background,设置圆角,边框,都通过这个来设置
  • visibility,gone是真正的消失,invisible是隐藏但占据布局位置,visible是默认值

3.2 布局控件

代码在这里

3.2.1 LinearLayout

3.2.1.1 基础

<?xml version="1.0" encoding="utf-8"?>
<!--divider是相当于LinearLayout里面插入分割线-->
<LinearLayout 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:orientation="vertical"
    android:padding="10dp"
    android:showDividers="middle"
    android:divider="@drawable/divider"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".LinearLayoutActivity">
    <!--orientation是相当于flex布局里面的flex-direction-->
    <LinearLayout
        android:background="@drawable/border"
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:text="按钮1"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:text="按钮2"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:text="按钮3"/>
    </LinearLayout>
    <!--layout-gravity是相当于flex布局里面的align-self,副轴方向上对齐-->
    <LinearLayout
        android:background="@drawable/border"
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="100dp">
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_gravity="top"
            android:text="按钮1"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_gravity="center"
            android:text="按钮2"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_gravity="bottom"
            android:text="按钮3"/>
    </LinearLayout>
    <!--layout-weight是相当于flex布局里面的flex-grow,按权重分配剩余空间-->
    <LinearLayout
        android:background="@drawable/border"
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:text="按钮1"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="0dp"
            android:layout_weight="1"
            android:text="按钮2"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:text="按钮3"/>
    </LinearLayout>
</LinearLayout>

布局如上

效果如上

基础使用包括orientation,layout_weight和layout_gravity

3.2.1.2 分割线

<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".LinearLayoutActivity3">
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">
        <!--使用Divider来做固定宽度的间隔-->
        <LinearLayout
            android:background="@drawable/border"
            android:divider="@drawable/divider2"
            android:showDividers="middle"
            android:gravity="center"
            android:orientation="horizontal"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮1"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮2"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮3"/>
        </LinearLayout>
        <!--使用Divider来做固定宽度的间隔,beginning-->
        <LinearLayout
            android:background="@drawable/border"
            android:divider="@drawable/divider2"
            android:showDividers="beginning"
            android:orientation="horizontal"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮1"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮2"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮3"/>
        </LinearLayout>
        <!--使用Divider来做固定宽度的间隔,beginning-->
        <LinearLayout
            android:background="@drawable/border"
            android:divider="@drawable/divider2"
            android:gravity="right"
            android:showDividers="end"
            android:orientation="horizontal"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮1"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮2"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮3"/>
        </LinearLayout>
        <!--使用显式View来做均分间隔-->
        <LinearLayout
            android:background="@drawable/border"
            android:orientation="horizontal"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <View
                android:layout_width="0dp"
                android:background="@drawable/bg"
                android:layout_height="match_parent"
                android:layout_weight="1"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮1"/>
            <View
                android:layout_width="0dp"
                android:background="@drawable/bg"
                android:layout_height="match_parent"
                android:layout_weight="1"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮2"/>
            <View
                android:layout_width="0dp"
                android:background="@drawable/bg"
                android:layout_height="match_parent"
                android:layout_weight="1"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮3"/>
            <View
                android:layout_width="0dp"
                android:background="@drawable/bg"
                android:layout_height="match_parent"
                android:layout_weight="1"/>
        </LinearLayout>
    </LinearLayout>
</ScrollView>

分割线,用divider,注意只能使用固定宽度或高度的分割线

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android = "http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <size
        android:width = "10dp"
        android:height="10dp"/>
</shape>

divider的样式

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android = "http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <stroke
        android:width="2dp"
        android:color="@color/black"/>
</shape>

background的样式

效果图如上

3.2.1.3 gravity

<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".LinearLayoutActivity2">
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">
        <LinearLayout
            android:background="@drawable/border"
            android:orientation="horizontal"
            android:layout_width="match_parent"
            android:layout_height="80dp"
            android:gravity="right|center_vertical">
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮1"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮2"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮3"/>
        </LinearLayout>
        <LinearLayout
            android:background="@drawable/border"
            android:orientation="horizontal"
            android:layout_width="match_parent"
            android:layout_height="80dp"
            android:gravity="bottom|center_horizontal">
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮1"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮2"/>
            <Button
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:text="按钮3"/>
        </LinearLayout>
    </LinearLayout>
</ScrollView>

LinearLayout自身,还有一个gravity属性,它相当于flex布局里面的justify-content和align-content

总的来说,LinearLayout跟Html里面的flex布局,很相似,包含几个属性为:

  • 自身的orientation,相当于flex-direction
  • 自身divider与showDividers,在子控件里面插入空隙,注意只能是固定的空隙
  • 自身的gravity,相当于flex布局里面的justify-content和align-content,可以同时设置主轴和副轴的排列
  • 子控件的layout_gravity,相当于align-self,注意它和gravity是不同的两个属性,注意只能设置副轴方向的排列。
  • 子控件的layout_weight,相当于flex-grow

3.2.2 RelativeLayout

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:divider="@drawable/divider"
    android:showDividers="middle"
    android:padding="10dp"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".RelativeLayoutActivity">
    <RelativeLayout
        android:background="@drawable/border"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1">
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_centerHorizontal="true"
            android:layout_centerVertical="true"
            android:text="按钮1"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_alignParentLeft="true"
            android:layout_alignParentTop="true"
            android:text="按钮2"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_alignParentRight="true"
            android:layout_alignParentTop="true"
            android:text="按钮3"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_alignParentLeft="true"
            android:layout_alignParentBottom="true"
            android:text="按钮4"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_alignParentRight="true"
            android:layout_alignParentBottom="true"
            android:text="按钮5"/>
    </RelativeLayout>
    <RelativeLayout
        android:background="@drawable/border"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1">

        <Button
            android:id="@+id/centerButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true"
            android:text="按钮1" />
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_above="@id/centerButton"
            android:layout_toLeftOf="@id/centerButton"
            android:text="按钮2"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_above="@id/centerButton"
            android:layout_toRightOf="@id/centerButton"
            android:text="按钮3"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_below="@id/centerButton"
            android:layout_toLeftOf="@id/centerButton"
            android:text="按钮4"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_below="@id/centerButton"
            android:layout_toRightOf="@id/centerButton"
            android:text="按钮5"/>
    </RelativeLayout>
</LinearLayout>

RelativeLayout就是相对父级,或者相对其他子控件来布局了,也比较简单,包含属性有:

  • 相对父级,layout_centerHorizontal,layout_centerVertical,layout_alignParentLeft,layout_alignParentRight,layout_alignParentTop和layout_alignParentBottom
  • 相对其他子级,layout_toLeftOf,layout_toRightOf,layout_above,layout_below

效果如上

3.2.3 FrameLayout

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:divider="@drawable/divider"
    android:showDividers="middle"
    android:padding="10dp"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".RelativeLayoutActivity">
    <FrameLayout
        android:background="@drawable/border"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1">
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:text="按钮1"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_marginLeft="10dp"
            android:layout_marginTop="10dp"
            android:text="按钮2"/>
    </FrameLayout>
    <FrameLayout
        android:background="@drawable/border"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1">
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_gravity="left"
            android:text="按钮1"/>
        <Button
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_gravity="right"
            android:layout_marginRight="10dp"
            android:layout_marginTop="10dp"
            android:text="按钮2"/>
    </FrameLayout>
</LinearLayout>

FrameLayout就是将多个控件重叠在一起摆放,这是作为简单的布局了,没啥好说的,几个属性:

  • layout_gravity,相对父级的什么位置开始布局

效果如上

3.2.4 ConstraintLayout

参考资料,在这里:

事先声明,ConstraintLayout不是万能药,它过度依赖于各个组件之间的相对定位,过度使用的时候,会导致牵一发而动全身的问题。对于,控件之间缺少关联依赖的布局,应该尽可能使用LinearLayout。对于,控件之间有明显的依赖关系的布局,才去使用强大的ConstraintLayout,它明显是代替RelativeLayout的。

3.2.4.1 基础

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ConstraintLayoutActivity">
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView1_1"
            android:layout_width="wrap_content"
            android:layout_height="30dp"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="10dp"
            android:layout_marginLeft="10dp"
            android:background="#ff0000"
            android:text="TextView1"/>
        <TextView
            android:id="@+id/textView1_2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toRightOf="@id/textView1_1"
            app:layout_constraintTop_toTopOf="@id/textView1_1"
            android:background="#00ff00"
            android:text="TextView2"/>
    </androidx.constraintlayout.widget.ConstraintLayout>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView2_1"
            android:layout_width="wrap_content"
            android:layout_height="80dp"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="10dp"
            android:layout_marginLeft="10dp"
            android:background="#ff0000"
            android:text="TextView1"/>
        <TextView
            android:id="@+id/textView2_2"
            android:layout_marginLeft="10dp"
            android:layout_width="wrap_content"
            android:layout_height="30dp"
            app:layout_constraintLeft_toRightOf="@id/textView2_1"
            app:layout_constraintTop_toTopOf="@id/textView2_1"
            android:background="#00ff00"
            android:text="TextView2"/>
        <TextView
            android:id="@+id/textView2_3"
            android:layout_marginLeft="10dp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toRightOf="@id/textView2_1"
            app:layout_constraintBottom_toBottomOf="@id/textView2_1"
            android:background="#0000ff"
            android:text="TextView3"/>
    </androidx.constraintlayout.widget.ConstraintLayout>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView3_1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#ff0000"
            android:text="TextView1"/>
        <TextView
            android:id="@+id/textView3_2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="20dp"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintHorizontal_bias="0.8"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#ff0000"
            android:text="TextView1_bias"/>
        <TextView
            android:id="@+id/textView3_3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            android:background="#00ff00"
            android:text="TextView2"/>
        <TextView
            android:id="@+id/textView3_4"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="80dp"
            app:layout_constraintVertical_bias="0.3"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            android:background="#00ff00"
            android:text="TextView2_bias"/>
    </androidx.constraintlayout.widget.ConstraintLayout>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView4_1"
            android:layout_marginTop="10dp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="@+id/textView4_2"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#ff0000"
            android:text="TextView1"/>
        <TextView
            android:id="@+id/textView4_2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintLeft_toRightOf="@+id/textView4_1"
            app:layout_constraintRight_toRightOf="parent"
            android:background="#00ff00"
            android:text="TextView2"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView5_1"
            android:layout_marginLeft="10dp"
            android:layout_marginRight="10dp"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#ff0000"
            android:text="TextView1"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView6_1"
            android:layout_marginTop="10dp"
            android:layout_marginBottom="10dp"
            android:layout_width="wrap_content"
            android:layout_height="0dp"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:background="#ff0000"
            android:text="TextView1"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

布局如上

效果如上

ConstraintLayout既然是对RelativeLayout的扩展,那相对定位的能力必然是要更强大的。

  • 相对定位,left_rightOfXXX,可以填写parent或者对应的id。注意,a_bOfxxx,就是自己的a,与别人的b对齐。凡是xxxOf的,就是别人的xxx。增强在于,定位可以是left,top,bottom,right,start,end和baseline。基础布局中,我们仅需要配置left,与top就可以了。
  • 居中,将自身的width设置为wrap_content,并同时设置left_xxxOf,与right_xxxOf,就能实现水平居中。同理,将自身的height设置为wrap_content,并同时设置top_xxxOf,与bottom_xxxOf,就能实现垂直居中。另外,还提供了一个好用的layout_constraintHorizontal_bias与layout_constraintVertical_bias来配置居中的比例。
  • 展开铺满,铺满在ConstraintLayout不再使用match_parent,而是将自身的width设置为0dp,并同时设置left_xxxOf,与right_xxxOf就可以了。

3.2.4.2 链

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ConstraintLayoutActivity2">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView1_1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toLeftOf="@id/textView1_2"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#ff0000"
            app:layout_constraintHorizontal_chainStyle="spread"
            android:text="TextView1"/>
        <TextView
            android:id="@+id/textView1_2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toRightOf="@id/textView1_1"
            app:layout_constraintRight_toLeftOf="@id/textView1_3"
            app:layout_constraintTop_toTopOf="@id/textView1_1"
            android:background="#00ff00"
            android:text="TextView2"/>

        <TextView
            android:id="@+id/textView1_3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toRightOf="@id/textView1_2"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#0000ff"
            android:text="TextView2"/>
    </androidx.constraintlayout.widget.ConstraintLayout>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView2_1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toLeftOf="@id/textView2_2"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#ff0000"
            app:layout_constraintHorizontal_chainStyle="spread_inside"
            android:text="TextView1"/>
        <TextView
            android:id="@+id/textView2_2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toRightOf="@id/textView2_1"
            app:layout_constraintRight_toLeftOf="@id/textView2_3"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#00ff00"
            android:text="TextView2"/>

        <TextView
            android:id="@+id/textView2_3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toRightOf="@id/textView2_2"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#0000ff"
            android:text="TextView2"/>
    </androidx.constraintlayout.widget.ConstraintLayout>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView3_1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toLeftOf="@id/textView3_2"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#ff0000"
            app:layout_constraintHorizontal_chainStyle="packed"
            android:text="TextView1"/>
        <TextView
            android:id="@+id/textView3_2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toRightOf="@id/textView3_1"
            app:layout_constraintRight_toLeftOf="@id/textView3_3"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#00ff00"
            android:text="TextView2"/>

        <TextView
            android:id="@+id/textView3_3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toRightOf="@id/textView3_2"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#0000ff"
            android:text="TextView2"/>
    </androidx.constraintlayout.widget.ConstraintLayout>


    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView4_1"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toLeftOf="@id/textView4_2"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#ff0000"
            app:layout_constraintHorizontal_weight="1"
            android:text="TextView1"/>
        <TextView
            android:id="@+id/textView4_2"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toRightOf="@id/textView4_1"
            app:layout_constraintRight_toLeftOf="@id/textView4_3"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#00ff00"
            app:layout_constraintHorizontal_weight="2"
            android:text="TextView2"/>

        <TextView
            android:id="@+id/textView4_3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toRightOf="@id/textView4_2"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:background="#0000ff"
            android:text="TextView2"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

布局代码

效果如上

将互相定位的一行,或者一列称为链,我们对链有额外的属性配置:

  • layout_constraintHorizontal_chainStyle,相当于flex的justify-content中的,space-around,space-between和center。
  • layout_constraintHorizontal_weight,相当于flex的flex-grow。

3.2.4.3 布局辅助对象

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ConstraintLayoutActivity2">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView1_1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="20dp"
            android:background="#ff0000"
            app:layout_constraintHorizontal_chainStyle="spread"
            android:text="TextView1ddddd"/>
        <TextView
            android:id="@+id/textView1_2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            android:layout_marginTop="10dp"
            app:layout_constraintTop_toBottomOf="@id/textView1_1"
            android:background="#00ff00"
            android:text="TextView2"/>
        <androidx.constraintlayout.widget.Barrier
            android:id="@+id/barrier"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:barrierDirection="right"
            app:constraint_referenced_ids="textView1_1,textView1_2"/>

        <TextView
            android:id="@+id/textView1_3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="30dp"
            app:layout_constraintLeft_toRightOf="@id/barrier"
            app:layout_constraintTop_toTopOf="@id/textView1_1"
            android:background="#0000ff"
            android:text="TextView2"/>
    </androidx.constraintlayout.widget.ConstraintLayout>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView2_1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="20dp"
            android:background="#ff0000"
            app:layout_constraintHorizontal_chainStyle="spread"
            android:text="TextView1ddddd"/>
        <TextView
            android:id="@+id/textView2_2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="10dp"
            android:layout_marginLeft="20dp"
            app:layout_constraintLeft_toRightOf="@id/textView2_1"
            android:background="#00ff00"
            android:text="TextView2"/>
        <androidx.constraintlayout.widget.Barrier
            android:id="@+id/barrier2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:barrierDirection="bottom"
            app:constraint_referenced_ids="textView2_1,textView2_2"/>

        <TextView
            android:id="@+id/textView2_3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="20dp"
            app:layout_constraintTop_toBottomOf="@id/barrier2"
            app:layout_constraintLeft_toLeftOf="parent"
            android:background="#0000ff"
            android:text="TextView2"/>
    </androidx.constraintlayout.widget.ConstraintLayout>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/guideline"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layout_constraintGuide_percent="0.4"/>

        <TextView
            android:id="@+id/textView3_1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="@id/guideline"
            app:layout_constraintRight_toRightOf="@id/guideline"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="10dp"
            android:background="#0000ff"
            android:text="TextView2"/>
    </androidx.constraintlayout.widget.ConstraintLayout>


    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/guideline2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            app:layout_constraintGuide_percent="0.4"/>

        <TextView
            android:id="@+id/textView4_1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintBottom_toBottomOf="@id/guideline2"
            app:layout_constraintTop_toTopOf="@id/guideline2"
            android:layout_marginTop="10dp"
            android:background="#0000ff"
            android:text="TextView2"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

布局代码如上

效果如上

辅助对象,是以显式的xml节点来辅助布局,同时实际UI中并不显示出来。包括有:

  • Barrier,取多个组件的top,bottom,left,right其中一个的最值。其他组件可以依据Barrier来做相对定位。
  • Guideline,取parent的百分比,或者绝对值做横线,或者竖线。其他组件可以依据Guideline来做相对定位。

3.2.4.4 动态对象容器

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ConstraintLayoutActivity2">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView1_1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="20dp"
            android:background="#ff0000"
            app:layout_constraintHorizontal_chainStyle="spread"
            android:text="TextView1ddddd"/>
        <TextView
            android:id="@+id/textView1_2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            android:layout_marginTop="10dp"
            app:layout_constraintTop_toBottomOf="@id/textView1_1"
            android:background="#00ff00"
            android:text="TextView2"/>
        <androidx.constraintlayout.widget.Barrier
            android:id="@+id/barrier"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:barrierDirection="right"
            app:constraint_referenced_ids="textView1_1,textView1_2"/>

        <TextView
            android:id="@+id/textView1_3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="30dp"
            app:layout_constraintLeft_toRightOf="@id/barrier"
            app:layout_constraintTop_toTopOf="@id/textView1_1"
            android:background="#0000ff"
            android:text="TextView2"/>
        <androidx.constraintlayout.widget.Group
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:visibility="invisible"
            app:constraint_referenced_ids="textView1_1,textView1_3"/>
    </androidx.constraintlayout.widget.ConstraintLayout>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/border">
        <TextView
            android:id="@+id/textView2_1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="20dp"
            android:background="#ff0000"
            app:layout_constraintHorizontal_chainStyle="spread"
            android:text="TextView1ddddd"/>
        <TextView
            android:id="@+id/textView2_2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintTop_toTopOf="parent"
            android:layout_marginTop="10dp"
            android:layout_marginLeft="20dp"
            app:layout_constraintLeft_toRightOf="@id/textView2_1"
            android:background="#00ff00"
            android:text="TextView2"/>
        <androidx.constraintlayout.widget.Barrier
            android:id="@+id/barrier2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:barrierDirection="bottom"
            app:constraint_referenced_ids="textView2_1,textView2_2"/>
        <androidx.constraintlayout.widget.Placeholder
            android:id="@+id/placeholder"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="20dp"
            app:layout_constraintTop_toBottomOf="@id/barrier2"
            app:layout_constraintLeft_toLeftOf="parent"
            app:content="@id/textView2_3"
            />
        <TextView
            android:id="@+id/textView2_3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="#0000ff"
            android:text="TextView2"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

布局代码如上

效果如上

动态对象容器,主要是为了方便布局时的动态对象插入或者删除,显隐而已。例如有:

  • Group,对多个对象进行统一的显隐
  • Placeholder,承担布局对象,让View只配置自身代码,不需要配置布局代码。

3.2.5 GridLayout

Android的GridLayout有点残废,不能设置分割线样式,布局设定有有点怪

<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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=".GridLayoutActivity">
    <LinearLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <GridLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="10dp"
            android:columnCount="2"
            android:rowCount="3">
            <TextView
                android:layout_column="0"
                android:background="@drawable/border"
                android:layout_width="wrap_content"
                android:layout_height="30dp"
                android:text="文字1"/>
            <TextView
                android:layout_column="1"
                android:background="@drawable/border"
                android:layout_width="wrap_content"
                android:layout_height="30dp"
                android:text="文字2"/>
            <TextView
                android:layout_column="0"
                android:layout_columnSpan="2"
                android:background="@drawable/border"
                android:layout_width="wrap_content"
                android:layout_height="30dp"
                android:text="文字3"/>
            <TextView
                android:layout_column="0"
                android:background="@drawable/border"
                android:layout_width="wrap_content"
                android:layout_height="30dp"
                android:text="文字444"/>
            <TextView
                android:layout_column="1"
                android:background="@drawable/border"
                android:layout_width="wrap_content"
                android:layout_height="30dp"
                android:text="文字5"/>
        </GridLayout>
        <GridLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="10dp"
            android:columnCount="2"
            android:rowCount="3">
            <TextView
                android:layout_column="0"
                android:background="@drawable/border"
                android:layout_columnWeight="1"
                android:layout_height="30dp"
                android:text="文字1"/>
            <TextView
                android:layout_column="1"
                android:background="@drawable/border"
                android:layout_columnWeight="1"
                android:layout_height="30dp"
                android:text="文字2"/>
            <TextView
                android:layout_column="0"
                android:layout_columnSpan="2"
                android:background="@drawable/border"
                android:layout_columnWeight="1"
                android:layout_height="30dp"
                android:text="文字3"/>
            <TextView
                android:layout_column="0"
                android:background="@drawable/border"
                android:layout_columnWeight="1"
                android:layout_height="30dp"
                android:text="文字4"/>
            <TextView
                android:layout_column="1"
                android:background="@drawable/border"
                android:layout_columnWeight="1"
                android:layout_height="30dp"
                android:text="文字5"/>
        </GridLayout>
        <GridLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="10dp"
            android:columnCount="2"
            android:rowCount="3">
            <TextView
                android:layout_column="0"
                android:background="@drawable/border"
                android:layout_columnWeight="1"
                android:layout_height="30dp"
                android:text="文字1"/>
            <TextView
                android:layout_column="1"
                android:background="@drawable/border"
                android:layout_columnWeight="1"
                android:layout_height="30dp"
                android:text="文字2"/>
            <TextView
                android:layout_column="0"
                android:layout_columnSpan="2"
                android:background="@drawable/border"
                android:layout_height="30dp"
                android:layout_gravity="right"
                android:text="文字3"/>
            <TextView
                android:layout_column="0"
                android:background="@drawable/border"
                android:layout_height="30dp"
                android:layout_columnWeight="1"
                android:text="文字4"/>
            <TextView
                android:layout_column="1"
                android:background="@drawable/border"
                android:layout_height="30dp"
                android:layout_columnWeight="1"
                android:text="文字5"/>
        </GridLayout>
    </LinearLayout>
</ScrollView>

布局如上

效果如上

要点如下:

  • GridLayout的columnCount与rowCount设置行与列的总数量,layout_column,layout_columnSpan,layout_row,layout_rowSpan设置当前在哪一个单元格里面,行合并,和列合并是多少。
  • 单元格使用match_parent是不可行的,会导致与GridLayout一样的宽度。要使用layout_columnWeight或者layout_rowWeight,按照比例来分割剩余空间。
  • 相同列数的单元格,共享共同的宽度。不同列数的单元格,可以通过layout_gravity来设置自身的对齐方式。

3.3 自定义控件

代码在这里

3.3.1 嵌套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"
    android:background="#EEE">
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="5dp"
        android:text="Back"
        android:textColor="#FFF"/>
    <TextView
        android:id="@id/title"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_weight="1"
        android:background="@drawable/border"
        android:gravity="center"
        android:text="Title Text"
        android:textColor="#F00"
        android:textSize="24sp"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="5dp"
        android:text="Edit"
        android:textColor="#FFF"/>
</LinearLayout>

首先定义一个子控件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <include layout="@layout/my_title_layout"/>
</LinearLayout>

然后在另外一个控件中include它。这种定义子控件的方法比较简单,缺点就是子控件确实行为,只有UI,子控件没有封装业务逻辑。

3.3.2 嵌套kotlin子控件

<?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"
    android:background="#EEE">
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="5dp"
        android:text="Back"
        android:textColor="#FFF"/>
    <TextView
        android:id="@id/title"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_weight="1"
        android:background="@drawable/border"
        android:gravity="center"
        android:text="Title Text"
        android:textColor="#F00"
        android:textSize="24sp"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="5dp"
        android:text="Edit"
        android:textColor="#FFF"/>
</LinearLayout>

定义一样的子控件Layout

package com.example.myapplication;

import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater
import android.widget.LinearLayout
import kotlinx.android.synthetic.main.my_title_layout.view.*

//将xml与对应类绑定在一起,能将业务逻辑也封装在一起
class TitleLayout(context:Context,attrs:AttributeSet):LinearLayout(context,attrs) {
    init{
        LayoutInflater.from(context).inflate(R.layout.my_title_layout,this)
    }

    fun setTitle(input:String){
        title.setText(input);
    }
}

然后定义一个与父组件一样的Class,因为子控件的顶级控件为LinearLayout,所以这个也要继承LinearLayout。这个时候,我们可以封装业务逻辑了。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.example.myapplication.TitleLayout
        android:id="@+id/myTitleLayout"
        android:layout_marginTop="10dp"
        android:layout_height="wrap_content"
        android:layout_width="match_parent"/>
</LinearLayout>

主控件中引用自定义titleLayout的方式

package com.example.myapplication

import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import kotlinx.android.synthetic.main.activity_main.*

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

        //将默认的supportBar隐藏掉
        supportActionBar?.hide()

        myTitleLayout.setTitle("我是标题")
    }
}

主控件的代码中也能简单地引用titleLayout的业务逻辑。

3.4 列表控件

代码在这里

Android的列表控件都是虚拟列表的实现,所以性能都比较好,注意一下使用的套路就可以了。

3.4.1 ListView

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    android:id="@+id/itemView"
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="60dp">
    <TextView
        android:id="@+id/text"
        android:layout_width="0dp"
        android:layout_weight="1"
        android:layout_height="wrap_content"
        android:layout_gravity="center_vertical"
        android:gravity="center" />

    <!--ListView下面的含有Button的时候,要将Button设置focusable为false,才能响应onItemClickListener-->
    <Button
        android:id="@+id/del"
        android:focusable="false"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_vertical"
        android:layout_marginLeft="10dp" />
</LinearLayout>

先定义ListView的每一个Item的Layout

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:id="@+id/button_add"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="添加一行"/>
    <Button
        android:id="@+id/button_add2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="添加一行,另外一种方法"/>
    <ListView
        android:id="@+id/listView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</LinearLayout>

主控件引入ListView的方式

package com.example.myapplication

data class Todo(var title:String,var user:String) {
}

定义数据条目的Todo

package com.example.myapplication;

import android.app.Activity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.TextView

//需要传递父级,Item的资源ID,和数据
class TodoAdapter(activity: Activity,val resourceId:Int,data:List<Todo>):ArrayAdapter<Todo>(activity,resourceId,data){

    inner class ViewHolder(val textView: TextView, val button:Button){}

    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        var view:View
        if( convertView == null ){
            //从context中生成Layout,Layout的资源ID为resourceId,Parent为parent,不绑定到root中
            view = LayoutInflater.from(context).inflate(resourceId,parent,false)

            //获取View,并缓存各自的数据对象
            val textView = view.findViewById<TextView>(R.id.text)
            val button = view.findViewById<Button>(R.id.del)
            view.tag = ViewHolder(textView, button)
        }else{
            //沿用现有的convertView
            view = convertView;
        }

        //填充数据
        var viewHolder = view.tag as ViewHolder
        val todo = getItem(position)
        if( todo != null ){
            viewHolder.button.setText("["+todo.user+"]添加")
            viewHolder.textView.setText(todo.title)
        }
        return view
    }
}

实现我们的ListView的Adapter,注意Adapter持有Content,Layout,和data三部分。

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_list_view_acitvity.*

class ListViewAcitvity : AppCompatActivity() {
    private val todoList = ArrayList<Todo>()

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

        initTodoList()
        //只需要实现一个Adapter就可以了
        val adapter = TodoAdapter(this,R.layout.todo_item,todoList)
        listView.adapter = adapter
        //ListView下面的含有Button的时候,要将Button设置focusable为false,才能响应onItemClickListener
        //看这里https://blog.csdn.net/yissan/article/details/50448950
        listView.setOnItemClickListener { _, _, position, _ ->
            Log.d("listView","click "+position)
            var todo = todoList[position]
            Toast.makeText(this,todo.title,Toast.LENGTH_SHORT).show()
        }

        button_add.setOnClickListener {
            val id = todoList.size+1;

            //修改todoList
            todoList.add(Todo("job_"+id,"fish_"+id))

            //需要手动通知Adapter发生了变化,否则不更新
            adapter.notifyDataSetChanged()
        }

        button_add2.setOnClickListener {
            val id = todoList.size+1;

            //使用Adapter来更新数据
            adapter.add(Todo("job_"+id,"fish_"+id))
        }
    }


    private fun initTodoList(){
        for( i in 0..1000){
            todoList.add(Todo("job_"+i,"fish_"+i))
        }
    }
}

要点:

  • 有了adapter和item的layout以后,我们就能配置ListView了,配置的方法也很简单,创建Adapter,传入ListView就可以了
  • ListView的itemClick,有专用listener
  • 数据变化的方式有两种,要么直接通过Adapter修改,要么修改List,然后通知Adapter数据变化了

效果如上

3.4.2 RecyclerView

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    android:id="@+id/itemView"
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="60dp">
    <TextView
        android:id="@+id/text"
        android:layout_width="0dp"
        android:layout_weight="1"
        android:layout_height="wrap_content"
        android:layout_gravity="center_vertical"
        android:gravity="center" />

    <!--ListView下面的含有Button的时候,要将Button设置focusable为false,才能响应onItemClickListener-->
    <Button
        android:id="@+id/del"
        android:focusable="false"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_vertical"
        android:layout_marginLeft="10dp" />
</LinearLayout>

todo item 的Layout和刚才一样

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:id="@+id/button_add"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="添加一行"/>
    <Button
        android:id="@+id/button_add2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="添加一行,性能更好的方法"/>
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</LinearLayout>

主控件的布局,这次换成了RecyclerView

package com.example.myapplication

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView

//仅仅需要传递ViewHolder,连data都不需要传递,这大幅增加了data的灵活性
class TodoAdapter2(val todoList:List<Todo>):RecyclerView.Adapter<TodoAdapter2.ViewHolder>() {

    inner class ViewHolder(view: View):RecyclerView.ViewHolder(view){
        var itemView:LinearLayout = view.findViewById(R.id.itemView)
        val title:TextView = view.findViewById(R.id.text)
        var del:Button = view.findViewById(R.id.del)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.todo_item,parent,false)
        var viewHolder =  ViewHolder(view)
        //RecyclerView默认没有边框线,需要手动添加
        viewHolder.itemView.setBackgroundResource(R.drawable.border)
        //使用View点击的方式
        viewHolder.itemView.setOnClickListener {
            //取得当前ViewHolder的位置
            var position = viewHolder.adapterPosition
            var todo = todoList[position]
            Toast.makeText(parent.context,todo.title, Toast.LENGTH_SHORT).show()
        }
        return viewHolder
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        var todo = todoList[position]
        holder.title.setText(todo.title)
        holder.del.setText("["+todo.user+"]添加")
    }

    //返回Item数量
    override fun getItemCount() = todoList.size
}

然后我们实现RecyclerView的Adapter,这次的Adapter设计更为合理,只需要传入ViewHolder就可以了。data与Activity都不再需要传入,更为灵活。

注意,ViewHolder上有itemView,adapterPosition这些重要的方法。

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.synthetic.main.activity_list_view_acitvity.*
import kotlinx.android.synthetic.main.activity_list_view_acitvity.button_add
import kotlinx.android.synthetic.main.activity_list_view_acitvity.button_add2
import kotlinx.android.synthetic.main.activity_recycle_view.*

class RecycleViewActivity : AppCompatActivity() {
    private val todoList = ArrayList<Todo>()

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

        initTodoList()
        //只需要实现一个Adapter就可以了
        val adapter = TodoAdapter2(todoList)
        val layoutManager = LinearLayoutManager(this)
        //要多传入一个排序属性
        recyclerView.layoutManager = layoutManager
        recyclerView.adapter = adapter

        //recyclerView不再有onItemClick的

        button_add.setOnClickListener {
            val id = todoList.size+1;

            //修改todoList
            todoList.add(Todo("job_"+id,"fish_"+id))

            //需要手动通知Adapter发生了变化,否则不更新
            adapter.notifyDataSetChanged()
        }

        button_add2.setOnClickListener {
            val id = todoList.size+1;

            //修改todoList
            todoList.add(Todo("job_"+id,"fish_"+id))
            //因为Adapter里面不再存储数据本身,所以没有add操作
            //adapter.add(Todo("job_"+id,"fish_"+id))
            //比notifyDataSetChanged更有效率的更新通知方式
            adapter.notifyItemInserted(todoList.size-1)
        }
    }


    private fun initTodoList(){
        for( i in 0..1000){
            todoList.add(Todo("job_"+i,"fish_"+i))
        }
    }
}

要点如下:

  • RecyclerView的配置不仅需要Adapter,还需要一个LayoutManager。因为RecyclerView同时支持List,Grid和瀑布等多种布局方式
  • RecyclerView的itemClickListener交给了ViewHolder来做了
  • 数据更改时,不再调用Adapter,而是手动更改数据,然后以多种方式通知Adapter数据变更了。

效果如上

3.4.2 RecyclerView的多个ViewType

package com.example.myapplication

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.recyclerview.widget.RecyclerView

//仅仅需要传递ViewHolder,连data都不需要传递,这大幅增加了data的灵活性
class TodoAdapter3(val todoList:List<Todo>):RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    inner class NormalViewHolder(view: View):RecyclerView.ViewHolder(view){
        val title:TextView = view.findViewById(R.id.text)
        var del:Button = view.findViewById(R.id.del)
    }

    inner class EditViewHolder(view: View):RecyclerView.ViewHolder(view){
        val title:TextView = view.findViewById(R.id.text)
        var del:TextView = view.findViewById(R.id.del)
    }

    val NormalMsgType = 1;
    var EditMsgType = 2;

    override fun getItemViewType(position: Int): Int {
        val todo = todoList[position]
        if( todo.title.contains("1")){
            return NormalMsgType;
        }else{
            return EditMsgType;
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        var viewHolder:RecyclerView.ViewHolder;
        if( viewType == NormalMsgType ){
            val view = LayoutInflater.from(parent.context).inflate(R.layout.todo_item,parent,false)
            viewHolder = NormalViewHolder(view)
        }else{
            val view = LayoutInflater.from(parent.context).inflate(R.layout.todo_item2,parent,false)
            viewHolder = EditViewHolder(view)
        }
        //RecyclerView默认没有边框线,需要手动添加
        viewHolder.itemView.setBackgroundResource(R.drawable.border)
        //使用View点击的方式
        viewHolder.itemView.setOnClickListener {
            //取得当前ViewHolder的位置
            var position = viewHolder.adapterPosition
            var todo = todoList[position]
            Toast.makeText(parent.context,todo.title, Toast.LENGTH_SHORT).show()
        }
        return viewHolder
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        var todo = todoList[position]
        when(holder){
            is NormalViewHolder->{
                holder.title.setText(todo.title)
                holder.del.setText("["+todo.user+"]添加")
            }
            is EditViewHolder->{
                holder.title.setText(todo.title)
                holder.del.setText("["+todo.user+"]添加")
            }
        }
    }

    //返回Item数量
    override fun getItemCount() = todoList.size
}

RecyclerView自身还支持多种ViewType,不再需要用一个FrameLayout来处理多个View的情况,难度也不高。

效果如上

3.5 RecyclerView高级用法

参考资料:

代码在这里

3.5.1 添加Header与Footer

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".RecyclerViewActivityWithHeader">
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</LinearLayout>

主页面布局

<?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="100dp">
    <TextView
        android:id="@+id/item"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:textSize="20sp"
        android:text="我是Header"
        android:background="@color/grey"/>
</LinearLayout>

头部布局

<?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="50dp"
    android:layout_margin="16dp">
    <TextView
        android:id="@+id/item"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:textSize="20sp"
        android:gravity="center"
        android:background="@color/grey"/>
</LinearLayout>

条目布局,注意有layout_margin

<?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="100dp">
    <TextView
        android:id="@+id/item"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:textSize="20sp"
        android:text="我是Footer"
        android:gravity="center"
        android:background="@color/grey"/>
</LinearLayout>

底部布局

package com.example.myapplication

import androidx.recyclerview.widget.RecyclerView
import android.widget.TextView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup


class DataAdapterWithHeader(private var mDatas:List<String>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    val TYPE_HEADER = 0 //说明是带有Header的

    val TYPE_FOOTER = 1 //说明是带有Footer的

    val TYPE_NORMAL = 2 //说明是不带有header和footer的

    //HeaderView, FooterView
    private var mHeaderView: View? = null
    private var mFooterView: View? = null

    //HeaderView和FooterView的get和set函数
    fun getHeaderView(): View? {
        return mHeaderView
    }

    fun setHeaderView(headerView: View?) {
        mHeaderView = headerView
        notifyItemInserted(0)
    }

    fun getFooterView(): View? {
        return mFooterView
    }

    fun setFooterView(footerView: View?) {
        mFooterView = footerView
        notifyItemInserted(getItemCount() - 1)
    }

    /** 重写这个方法,很重要,是加入Header和Footer的关键,我们通过判断item的类型,从而绑定不同的view    * */
    override fun getItemViewType(position: Int): Int {
        if ( mHeaderView != null && position == 0) {
            //第一个item应该加载Header
            return TYPE_HEADER
        }
        if( mFooterView != null && position == getItemCount() - 1){
            return TYPE_FOOTER
        }
        return TYPE_NORMAL
    }

    //创建View,如果是HeaderView或者是FooterView,直接在Holder中返回
    override  fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        if (mHeaderView != null && viewType == TYPE_HEADER) {
            return ListHolder(mHeaderView!!)
        }
        if (mFooterView != null && viewType == TYPE_FOOTER) {
            return ListHolder(mFooterView!!)
        }
        val layout: View =
            LayoutInflater.from(parent.context).inflate(R.layout.cyclerview_item, parent, false)
        return ListHolder(layout)
    }

    //绑定View,这里是根据返回的这个position的类型,从而进行绑定的,   HeaderView和FooterView, 就不同绑定了
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if (getItemViewType(position) == TYPE_NORMAL) {
            if (holder is ListHolder) {
                var realPosition = position
                if( mHeaderView != null ){
                    //这里加载数据的时候要注意,是从position-1开始,因为position==0已经被header占用了
                    realPosition = realPosition -1
                }
                holder.tv?.text = mDatas[realPosition]
            }
            return
        } else{
            return
        }
    }

    //在这里面加载ListView中的每个item的布局
    inner class ListHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        var tv: TextView? = null

        init {
            //如果是headerview或者是footerview,直接返回
            if (itemView === mHeaderView) {

            }else if( itemView == mFooterView ){

            }else{
                tv = itemView.findViewById(R.id.item)
            }
        }
    }

    //返回View中Item的个数,这个时候,总的个数应该是ListView中Item的个数加上HeaderView和FooterView
    override fun getItemCount(): Int {
        return if (mHeaderView == null && mFooterView == null) {
            mDatas.size
        } else if (mHeaderView == null && mFooterView != null) {
            mDatas.size + 1
        } else if (mHeaderView != null && mFooterView == null) {
            mDatas.size + 1
        } else {
            mDatas.size + 2
        }
    }
}

Header与Footer的实现关键在于Adapter,对index进行判断,返回不同的View。

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.synthetic.main.activity_recycler_view_with_header.*
import android.view.LayoutInflater

class RecyclerViewActivityWithHeader : AppCompatActivity() {
    val data:List<String>

    init{
        val listData = ArrayList<String>()
        for( i in 0..100){
            listData.add(i.toString()+"_txt")
        }
        this.data = listData
    }

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

        val linearLayoutManager = LinearLayoutManager(this)
        val adapter = DataAdapterWithHeader(this.data)
        recycler_view.layoutManager = linearLayoutManager
        recycler_view.adapter = adapter

        //设置Header与Footer
        val header = LayoutInflater.from(this).inflate(R.layout.cyclerview_header, recycler_view, false)
        adapter.setHeaderView(header)
        val footer = LayoutInflater.from(this).inflate(R.layout.cyclerview_footer, recycler_view, false)
        adapter.setFooterView(footer)
    }
}

入口文件

效果如上

3.5.2 加入分割线

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="#FF0000"/>
    <size android:height="1dp"/>
</shape>

加入border文件

package com.example.myapplication
import android.R
import android.content.Context
import android.content.res.TypedArray
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.util.Log
import android.view.View
import androidx.recyclerview.widget.RecyclerView

import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView.ItemDecoration

/** * Created by wnw on 16-5-22.  */
class LinearRecycleViewDecoration(private val mContext: Context, orientation: Int,drawableResource:Int) :
    ItemDecoration() {

    private val mDivider: Drawable

    private var mOrientation = 0

    init {
        mDivider = mContext.resources.getDrawable(drawableResource)
        setOrientation(orientation)
    }


    //设置屏幕的方向
    private fun setOrientation(orientation: Int) {
        require(!(orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST)) { "invalid orientation" }
        mOrientation = orientation
        Log.d("Decorator","height = ${mDivider.intrinsicHeight} and width = ${mDivider.intrinsicWidth}")
    }

    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        if (mOrientation == HORIZONTAL_LIST) {
            drawVerticalLine(c, parent, state)
        } else {
            drawHorizontalLine(c, parent, state)
        }
    }

    //画横线, 这里的parent其实是显示在屏幕显示的这部分
    private fun drawHorizontalLine(c: Canvas?, parent: RecyclerView, state: RecyclerView.State?) {
        val left = parent.paddingLeft
        val right = parent.width - parent.paddingRight
        val childCount = parent.childCount
        for (i in 0 until childCount) {
            val child = parent.getChildAt(i)

            //获得child的布局信息
            val params = child.layoutParams as RecyclerView.LayoutParams
            val top = child.bottom + params.bottomMargin
            val bottom = top + mDivider.intrinsicHeight
            mDivider.setBounds(left, top, right, bottom)
            mDivider.draw(c!!)
            Log.d("Decorator", "drawHorizontalLine ${left}   ${right} ${top} ${bottom} ${i}");
        }
    }

    //画竖线
    private fun drawVerticalLine(c: Canvas?, parent: RecyclerView, state: RecyclerView.State?) {
        val top = parent.paddingTop
        val bottom = parent.height - parent.paddingBottom
        val childCount = parent.childCount
        for (i in 0 until childCount) {
            val child = parent.getChildAt(i)

            //获得child的布局信息
            val params = child.layoutParams as RecyclerView.LayoutParams
            val left = child.right + params.rightMargin
            val right = left + mDivider.intrinsicWidth
            mDivider.setBounds(left, top, right, bottom)
            mDivider.draw(c!!)
            Log.d("Decorator", "drawVerticalLine ${left}   ${right} ${top} ${bottom} ${i}");
        }
    }

    //由于Divider也有长宽高,每一个Item需要向下或者向右偏移
    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        if (mOrientation == HORIZONTAL_LIST) {
            //画横线,就是往下偏移一个分割线的高度
            outRect[0, 0, 0] = mDivider.intrinsicHeight
        } else {
            //画竖线,就是往右偏移一个分割线的宽度
            outRect[0, 0, mDivider.intrinsicWidth] = 0
        }
    }

    companion object {
        const val HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL
        const val VERTICAL_LIST = LinearLayoutManager.VERTICAL
    }
}

加入ItemDecoration

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.synthetic.main.activity_recycler_view_with_divider.*

class RecyclerViewActivityWithDivider : AppCompatActivity() {
    val data:List<String>

    init{
        val listData = ArrayList<String>()
        for( i in 0..100){
            listData.add(i.toString()+"_txt")
        }
        this.data = listData
    }

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


        val linearLayoutManager = LinearLayoutManager(this)
        val adapter = DataAdapterWithHeader(this.data)
        val itemDecoration = LinearRecycleViewDecoration(this,LinearRecycleViewDecoration.VERTICAL_LIST,R.drawable.border)
        recycler_view.layoutManager = linearLayoutManager
        recycler_view.adapter = adapter
        recycler_view.addItemDecoration(itemDecoration)
    }
}

分割线通过addItemDecoration来实现,没啥好说的。ListView只需要用一个divider样式就可以了

4 Fragment

代码在这里

4.1 自定义View的动态替换

package com.example.myapplication.view

import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.Button
import android.widget.LinearLayout
import com.example.myapplication.R

class LeftView(context:Context,attrs:AttributeSet):LinearLayout(context,attrs) {
     private lateinit var buttonView:Button

    init{
        val view = LayoutInflater.from(context).inflate(R.layout.fragment_left,this)
        buttonView = view.findViewById(R.id.changeButton)
    }

    fun setButtonText(input:String){
        buttonView.setText(input);
    }
}

定义一个LeftView

package com.example.myapplication.view

import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.Button
import android.widget.LinearLayout
import com.example.myapplication.R

class RightView(context:Context):LinearLayout(context) {

    init{
        LayoutInflater.from(context).inflate(R.layout.fragment_right,this)
    }

}

定义一个RightView,注意没有AttributeSet参数,因为这个View是手动实例的,不是通过xml实例出来的。

package com.example.myapplication.view

import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.Button
import android.widget.LinearLayout
import com.example.myapplication.R

class AnotherRightView(context:Context):LinearLayout(context) {

    init{
        LayoutInflater.from(context).inflate(R.layout.fragment_another_right,this)
    }

}

定义另外一个AnotherRightView。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".fragment.FragmentActivity">
    <com.example.myapplication.view.LeftView
        android:id="@+id/leftView"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:layout_width="match_parent"/>
    <FrameLayout
        android:background="@drawable/border"
        android:id="@+id/rightLayout"
        android:layout_height="0dp"
        android:layout_width="match_parent"
        android:layout_weight="1">
    </FrameLayout>
</LinearLayout>

主页面的布局,使用Framelayout来做动态页面替换的容器,这可是一个基础套路了。

package com.example.myapplication.view

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import com.example.myapplication.R
import com.example.myapplication.fragment.AnotherRightFragment
import com.example.myapplication.fragment.LeftFragment
import com.example.myapplication.fragment.RightFragment
import kotlinx.android.synthetic.main.activity_user_view.*
import kotlinx.android.synthetic.main.fragment_left.*

class UserViewActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_user_view)

        changeButton.setOnClickListener {
            replaceTop(AnotherRightView(this))
            leftView.setButtonText("按钮改变了")
        }

        replaceTop(RightView(this))
    }

    private fun replaceTop(view:View){
        rightLayout.removeAllViews()
        rightLayout.addView(view)
    }
}

主页面的逻辑,也比较简单,就是点击按钮以后,动态替换View。

效果如上。

使用自定义View的动态替换,有两个问题:

  • 下方的View没有Activity栈,替换了View以后,如果按下Back键,会退出整个Activity,而不是返回上一个View。
  • 自定义View无法知道Activity的生命周期。

类似这种的平板UI中,左侧菜单栏是保持不变的,但是右侧的视图是有自己的栈

4.2 Fragment的动态替换

为了解决以上的问题,Android提出了Fragment的概念。注意,我们要使用androidX里面的Fragment,不要使用系统自带的Fragment,否则会有比较多兼容性问题。

package com.example.myapplication.fragment

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import com.example.myapplication.R


class LeftFragment : Fragment() {
    lateinit var changeButton:Button;

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val view = inflater.inflate(R.layout.fragment_left, container, false)
        this.changeButton = view.findViewById<Button>(R.id.changeButton)
        return view
    }

    fun setButtonText(text:String){
        changeButton.setText(text)
    }

}

先定义一个LeftFragment,封装有自己的业务逻辑

package com.example.myapplication.fragment

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.example.myapplication.R

class RightFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_right, container, false)
    }
}

定义一个RightFragment

package com.example.myapplication.fragment

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.example.myapplication.R

class AnotherRightFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_another_right, container, false)
    }
}

定义一个AnotherRightFragment

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".fragment.FragmentActivity">
    <!--Fragment必须加上ID,否则会报错-->
    <fragment
        android:id="@+id/leftFragment"
        android:layout_height="0dp"
        android:layout_width="match_parent"
        android:layout_weight="1"
        android:name="com.example.myapplication.fragment.LeftFragment"/>
    <FrameLayout
        android:background="@drawable/border"
        android:id="@+id/rightLayout"
        android:layout_height="0dp"
        android:layout_width="match_parent"
        android:layout_weight="1">
    </FrameLayout>
</LinearLayout>

这是主页面的布局,注意,所有在XML中定义的fragment都需要一个id,否则启动会报错。

package com.example.myapplication.fragment

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import com.example.myapplication.R
import kotlinx.android.synthetic.main.fragment_left.*

class FragmentActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_fragment)

        changeButton.setOnClickListener {
            replaceFragment(AnotherRightFragment())
            //获取当前的Fragment
            var leftFragment = supportFragmentManager.findFragmentById(R.id.leftFragment) as LeftFragment
            leftFragment.setButtonText("按钮改了!")
        }

        replaceFragment(RightFragment())
    }
    private fun replaceFragment(fragment:Fragment){
        var fragmentManager = supportFragmentManager
        val transaction = fragmentManager.beginTransaction()
        //supportFragmentManager.fragments.size表示当前页面有多少个Fragments,而不是栈里面有多少个Fragment
        //前后都是1,commit以后才会改变
        Log.d("fragment before",supportFragmentManager.fragments.size.toString())
        transaction.replace(R.id.rightLayout,fragment)
        Log.d("fragment after",supportFragmentManager.fragments.size.toString())
        if( supportFragmentManager.fragments.size > 1){
            transaction.addToBackStack(null)
        }
        transaction.commit()
    }
}

这是主页面代码,注意获取,和设置Fragment的相关代码。

效果如上,这个时候按下Back键,能自动展示上一次的Fragment了。

4.3 Fragment的生命周期

Fragment的生命周期与Activity很相似了。

  • onCreate是在栈
  • onStart是在栈,且可见
  • onResume是在栈,且可见,且在栈顶

要注意的是,创建Layout的位置一般在onCreateView,而不是在onCreate。

具体看这里

5 Boardcast

Boardcast相当于后端中的事件接收器,Android中支持静态注册,和动态注册两种。暂时没用到,我就不写笔记了

Boardcast的主要作用为:

  • 接收来自系统的广播,wifi连上和掉线了,蜂窝网络连上和掉线了,tick的事件等等
  • 接收其他App的通知,这称为隐式广播,这种方式现在被Android禁用得比较多了
  • 接收本App的Activity或者服务的通知,这个做法比较少了,直接绑定当前进程的通知开销更少,实时性更高。

6 持久化

代码在这里

6.1 文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".FileActivity">
    <EditText
        android:id="@+id/editText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="请输入"/>
</LinearLayout>

布局文件

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import kotlinx.android.synthetic.main.activity_file.*
import java.io.*
import java.lang.StringBuilder

class FileActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_file)
        val inputText = load()
        if( inputText.isNotEmpty() ){
            editText.setText(inputText)
            editText.setSelection(inputText.length)
            Toast.makeText(this,"Restoring succeded",Toast.LENGTH_SHORT).show()
        }
    }

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

    private fun save(content:String){
        try{
            //MODE_PRIVATE是清空重新写入,MODE_APPEND是尾部添加
                //这种方式读写文件,不允许有路径
            val output = openFileOutput("data.txt", MODE_PRIVATE)
            val writer = BufferedWriter(OutputStreamWriter(output))
            writer.use{
                it.write(content)
            }
        }catch(e:IOException){
            e.printStackTrace()
        }
    }

    private fun load():String{
        var content = StringBuilder()
        try{
            //这种方式读写文件,不允许有路径
            val input = openFileInput("data.txt")
            val reader = BufferedReader(InputStreamReader(input))
            reader.use {
                reader.forEachLine {
                    content.append(it+"\n")
                }
            }
        }catch(e:IOException){
            e.printStackTrace()
        }
        return content.toString()
    }
}

使用Activity自带的openFileInput和openFileOutput,作为读写文件的工具,注意不能附带任何的路径分割符。文件固定存放在/data/data/package.name/files目录中。

在Android Studio的View->Tools->Device File Explorer,或者右下角的Device File Explorer可以看到文件管理器,在那里我们能看到模拟器里面的文件。

6.2 SQLLite

6.2.1 SQLHelper

package com.example.myapplication

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

class MySqlDatabaseHelper(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,"+
            "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(createBook)
        }
        if( oldVersion <= 2 ){
            db.execSQL(createCategory)
        }
    }
}

先创建MySqlDatabaseHelper,主要是负责Sql版本的Schema。另外,Schema需要支持不同版本的渐进更新。

6.2.2 CURD

package com.example.myapplication

import android.annotation.SuppressLint
import android.database.Cursor
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import kotlinx.android.synthetic.main.activity_sqlite.*
import java.lang.Exception

class SQLiteActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sqlite)

        val dbHeper = MySqlDatabaseHelper(this,"BookStore.db",2)

        add.setOnClickListener {
            val db = dbHeper.writableDatabase
            //开始事务
            db.beginTransaction()
            try{
                db.execSQL("insert into Book(author,name,category_id)values(\"fish\",\"book1\",1001)")
                //提交事务
                db.setTransactionSuccessful()
            }catch(e:Exception){
                e.printStackTrace()
            }finally {
                //结束事务
                db.endTransaction()
            }
        }

        del.setOnClickListener {
            val db = dbHeper.writableDatabase
            db.execSQL("delete from Book where author = \"fish\"")
        }

        mod.setOnClickListener {
            val db = dbHeper.writableDatabase
            db.execSQL("update Book set author = \"fish2\" where author = \"fish\"")
        }

        query.setOnClickListener {
            val db = dbHeper.readableDatabase
            val cursor = db.rawQuery("select id,author,name,category_id from Book where category_id = ?",arrayOf("1001"))
            if( cursor.moveToFirst()){
                do{
                    @SuppressLint("RANGE")
                    val id = cursor.getInt(cursor.getColumnIndex("id"))
                    @SuppressLint("RANGE")
                    val author = cursor.getString(cursor.getColumnIndex("author"))
                    @SuppressLint("RANGE")
                    val name = cursor.getString(cursor.getColumnIndex("name"))
                    @SuppressLint("RANGE")
                    val category_id = cursor.getInt(cursor.getColumnIndex("category_id"))
                    Log.d("Book","id ${id},author ${author},name ${name},category_id ${category_id}")
                }while(cursor.moveToNext())
            }
            cursor.close()
        }
    }
}

有了SqlHelper以后,我们需要传入Context,database的存储文件名,和当前的版本号。

  • 新增,修改,删除,都用execSQL
  • 查询用rawQuery

都挺简单的,没啥好说

/data/data/xxxx/databases

database存储在以上的路径上

6.2.3 远程查看database

首先打开Android Studio,查看当前的Android SDK的位置

export ANDROID_HOME=/Users/xx/Library/Android/sdk:/Users/xx/Library/Android/sdk/platform-tools
export PATH=$PATH:$ANDROID_HOME

然后将Android SDK的位置,包括根目录和platform-tools都加入到PATH变量中

adb shell
cd /data/data/com.example.myapplication/databases

打开SQLite数据库所在的位置

sqlite3 xxx.db

使用sqlite3打开对应的db文件,就能远程查询db数据了

6.2.4 经验

orderDao.modOrder(order)
orderItemDao.delByOrderId(id)
orderItemDao.addAll(items)

在Android中,数据走到一半被后台强行kill掉是很正常的,所以要特别注意将多个操作放入事务中进行。否则就会出现数据不一致的情况。例如,在上面的例子中,如果进程在del以后被kill掉的,那么这个订单的item数据就会全部丢失了。所以,要把一个聚合根的数据以事务的方式写入数据库,保证不会出现不一致的情况。

另外,在SqlLite的数据库中,事务是文件级别的,如果一个操作在走写事务,那么其他操作就走不了读操作。这种方式并发度比较低,但是可靠性也容易做,也不容易出现死锁问题。

7 ContentProvider

共享数据,相当于Android提供一个通用的协议,让本App的数据可以暴露给其他App读写。协议上对SQLite的支持相当友好,所以一般App数据也用SQLite来实现的。暂时没用到,我也是不写笔记了。

ContentProvider主要作用有:

  • 读取系统App的数据,短信,通讯录,通话记录,日程,联系人,当前定位,麦克风,电话状态,传感器,外置存储等信息。
  • 提供自身App数据给其他App使用,应用也比较少。

8 多媒体

代码在这里

8.1 通知

8.1.1 普通通知

package com.example.myapplication

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.core.app.NotificationCompat
import kotlinx.android.synthetic.main.activity_notify.*

class NotifyActivity : AppCompatActivity(),View.OnClickListener{
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_notify)
        this.createChannel()
        normalNotify.setOnClickListener(this)
        largeTextNotify.setOnClickListener(this)
        largeImageNotify.setOnClickListener(this)
        startService.setOnClickListener {
            val intent = Intent(this,MyService::class.java)
            startService(intent)
        }
        stopService.setOnClickListener {
            var intent = Intent(this,MyService::class.java)
            stopService(intent)
        }
    }

    private lateinit var manager:NotificationManager;

    private fun createChannel(){
        this.manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ){
            val channel = NotificationChannel("normal","Normal",NotificationManager.IMPORTANCE_DEFAULT)
            this.manager.createNotificationChannel(channel)
        }
    }

    override fun onClick(v: View?) {
        if( v == normalNotify){
            var intent = Intent(this,MainActivity::class.java)
            var 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)
                .setLargeIcon(BitmapFactory.decodeResource(resources,R.drawable.large))
                .setContentIntent(pi)//点击后的打开
                .setAutoCancel(true)//点击后自动删除通知
                .build()
            //notify的id用来区分重复的通知
            this.manager.notify(1,notification)
        }else if( v == largeTextNotify){
            var intent = Intent(this,MainActivity::class.java)
            var pi = PendingIntent.getActivity(this,0,intent,0)
            //注意传入渠道名
            val notification = NotificationCompat.Builder(this,"normal")
                .setContentTitle("This is Content Title")
                .setStyle(NotificationCompat.BigTextStyle().bigText("Learn how to to build notifications, send and sync data, and use voice actions.Get the official Android IDE and developer tools to build apps for Android"))
                .setSmallIcon(R.drawable.small)
                .setLargeIcon(BitmapFactory.decodeResource(resources,R.drawable.large))
                .setContentIntent(pi)//点击后的打开
                .setAutoCancel(true)//点击后自动删除通知
                .build()
            //notify的id用来区分重复的通知
            this.manager.notify(2,notification)
        }else if( v == largeImageNotify){
            var intent = Intent(this,MainActivity::class.java)
            var pi = PendingIntent.getActivity(this,0,intent,0)
            //注意传入渠道名
            val notification = NotificationCompat.Builder(this,"normal")
                .setContentTitle("This is Content Title")
                .setStyle(NotificationCompat.BigPictureStyle().bigPicture(BitmapFactory.decodeResource(resources,R.drawable.large)))
                .setSmallIcon(R.drawable.small)
                .setLargeIcon(BitmapFactory.decodeResource(resources,R.drawable.large))
                .setContentIntent(pi)//点击后的打开
                .setAutoCancel(true)//点击后自动删除通知
                .build()
            //notify的id用来区分重复的通知
            this.manager.notify(3,notification)
        }
    }
}

Android的通知现在需要先注册Channel,然后发送的时候指定Channel来发送,另外就是每个通知都需要一个ID,用来去重。建立通知有几种方法:

  • 使用NotificationCompat.build,来设置setContentTitle和setContentText
  • 使用NotificationCompat.build,来设置setContentTitle和BigTextStyle的setStyle
  • 使用NotificationCompat.build,来设置setContentTitle和BigPictureStyle的setStyle

8.1.2 前台服务通知

package com.example.myapplication

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat

class MyService : Service() {

    private fun createChannel():NotificationManager{
        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ){
            val channel = NotificationChannel("my_service","前台Service通知", NotificationManager.IMPORTANCE_DEFAULT)
            manager.createNotificationChannel(channel)
        }
        return manager
    }

    override fun onBind(intent: Intent?): IBinder? {
        TODO("Not yet implemented")
    }

    override fun onCreate() {
        super.onCreate()
        Log.d("MyService","onCreate")
        var intent = Intent(this,MainActivity::class.java)
        var 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)
            .setLargeIcon(BitmapFactory.decodeResource(resources,R.drawable.large))
            .setContentIntent(pi)//点击后的打开
            .build()
        //启动前台通知
        //前台通知的特点是,与服务绑定在一起,通知在的时候,服务就在,通知不在的时候,服务就不在。
        //前台服务无法简单地划走,用户需要明确指定关闭该服务的时候,才能关闭它
        startForeground(1,notification)
    }
}

前台服务通知,就是将服务与一个通知绑定在一起,显式地告诉用户有这样的一个后台服务在运行。当用户需要删除后台服务的时候,仅仅需要关掉通知就可以了。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.myapplication">
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
    <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/Theme.MyApplication">
        <service
            android:name=".MyService"
            android:enabled="true"
            android:exported="true"></service>
        <activity
            android:name=".NotifyActivity"
            android:exported="false" />
    </application>

</manifest>

注意配置的时候,要打开FOREGROUND_SERVICE的权限,以及注册MyService的服务

startService.setOnClickListener {
    val intent = Intent(this,MyService::class.java)
    startService(intent)
}
stopService.setOnClickListener {
    var intent = Intent(this,MyService::class.java)
    stopService(intent)
}

启动和停止服务的方式

8.2 拍照

FileProvider看这里,从Android 7.0开始版本,不同App之间传递数据,不允许继续使用file://的方法了,因为这样做有安全隐患。安全的办法是,不同App之间使用content://的方法来传递数据。为什么使用content://来传递uri就安全了呢?

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.myapplication">
    <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/Theme.MyApplication">
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="com.fishedee.fileProvider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths"/>
        </provider>
    </application>

</manifest>

因为有FileProvider,每个App启动的时候,都先注册一个FileProvider,并填写自己专属的authorities。然后每个要往外传递的文件,都需要先用FileProvider的getUriForFile转换一下为随机的文件名。然后对方的App要读写这个uri的时候,就需要反向访问你这个App的FileProvider(ContentProvider机制)来实现,对方无法知道这个文件实际上对应硬盘上的哪个文件(对方只有一个随机的文件名,没有真实的文件名)。同时,对方也无法访问你没有授权过的文件(因为只有用FileProvider的getUriForFile转换过的文件,才能被其他App访问)

最后,有了FileProvider转换过的文件名,都需要用ContentResolver来反向读取它。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".CameraActivity">
    <Button
        android:id="@+id/takePhoto"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="拍照"/>
    <ImageView
        android:id="@+id/imageView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:layout_gravity="center_vertical"/>
</LinearLayout>

布局文件

package com.example.myapplication

import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
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 kotlinx.android.synthetic.main.activity_camera.*
import java.io.File

class CameraActivity : AppCompatActivity() {

    private lateinit var imageUri:Uri;

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_camera)
        takePhoto.setOnClickListener {
            getNewFileUri()
            val intent = Intent("android.media.action.IMAGE_CAPTURE")
            intent.putExtra(MediaStore.EXTRA_OUTPUT,imageUri)
            startActivityForResult(intent,100)
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        when( requestCode){
            100->{
                if( resultCode == RESULT_OK){
                    val bitmap = BitmapFactory.decodeStream(contentResolver.openInputStream(imageUri))
                    imageView.setImageBitmap(bitmap)
                }
            }
        }
    }

    private fun getNewFileUri(){
        val outputImage = File(externalCacheDir,"output_image.jpg")
        if( outputImage.exists() ){
            outputImage.delete()
        }
        outputImage.createNewFile()
        if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ){
            imageUri = FileProvider.getUriForFile(this,"com.fishedee.fileProvider",outputImage)
        }else{
            imageUri = Uri.fromFile(outputImage)
        }
    }
}

代码,其实并不困难,就是FileProvider比较麻烦。

8.3 相册

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:id="@+id/selectAlbum"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="相册选择"/>
    <Button
        android:id="@+id/selectAlbum2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="相册选择2"/>
    <ImageView
        android:id="@+id/imageView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:layout_gravity="center_vertical"/>
</LinearLayout>

布局文件,也不困难

package com.example.myapplication

import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import kotlinx.android.synthetic.main.activity_album.*
import kotlinx.android.synthetic.main.activity_album.imageView
import kotlinx.android.synthetic.main.activity_camera.*
import java.io.FileDescriptor

class AlbumActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_album)
        selectAlbum.setOnClickListener {
            var intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
            intent.addCategory(Intent.CATEGORY_OPENABLE)
            intent.type="image/*"
            startActivityForResult(intent,100)
        }
        selectAlbum2.setOnClickListener {
            var intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
            intent.addCategory(Intent.CATEGORY_OPENABLE)
            intent.type="image/*"
            startActivityForResult(intent,101)
        }
    }

    private fun getUriBitmap(uri: Uri):Bitmap?{
        val fd = contentResolver.openFileDescriptor(uri,"r")
        //使用openFileDescriptor的话要记得放在use里面关闭
        fd.use {
            if( fd != null ){
                return android.graphics.BitmapFactory.decodeFileDescriptor(fd.fileDescriptor)
            }else{
                return null
            }
        }
    }

    private fun getUriBitmap2(uri:Uri):Bitmap{
        val bitmap = BitmapFactory.decodeStream(contentResolver.openInputStream(uri))
        return bitmap
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if( requestCode == 100 ){
            if( resultCode == RESULT_OK && data != null ){
                data.data?.let { uri->
                    val bitmap = this.getUriBitmap(uri)
                    imageView.setImageBitmap(bitmap)
                }
            }
        }else if( requestCode == 101 ){
            if( resultCode == RESULT_OK && data != null ){
                data.data?.let { uri->
                    val bitmap = this.getUriBitmap2(uri)
                    imageView.setImageBitmap(bitmap)
                }
            }
        }
    }
}

相册选择读取文件的方式,也不困难。注意获取到了Uri以后,可以用contentResolver来获取FileDescriptor(注意要关闭文件),也可以直接openInputStream。

9 Service

后台服务,暂时没有用到,就不说了。

为什么不直接创建线程来做Service。因为Android的App是没有统一入口的,有时候是A页面进入,有时候是B页面进入,你无法确定该在哪里创建线程。另外,创建线程还需要考虑线程的重复性,你总不能在A页面和B页面的onCreate创建不同的线程(同一个任务),这样会导致线程太多了。

服务就是解决这个问题的,一个Service,无论start多少次,只有一个实例。然后,我们在Activity的onCreate的时候start这个服务,就能保证服务持久运作了。

10 网络

代码在这里

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.myapplication">

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

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:networkSecurityConfig="@xml/network_config"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyApplication">
        <activity
            android:name=".OkHttpActivity"
            android:exported="false" />
        <activity
            android:name=".RetrofitActivity"
            android:exported="false" />
        <activity
            android:name=".GsonActivity"
            android:exported="false" />
        <activity
            android:name=".WebviewActivity"
            android:exported="false" />
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="标题栏">
            <intent-filter>

                <!-- action为MAIN是指定入口的Activity -->
                <action android:name="android.intent.action.MAIN" />

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

</manifest>

网络部分,先配置好INTERNET权限,另外还要配置application的networkSecurityConfig

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true">
        <trust-anchors>
            <certificates src="system"/>
            <certificates src="user"/>
        </trust-anchors>
    </base-config>
</network-security-config>

networkSecurityConfig的作用是允许http而不是https通信。而且允许使用system和user的根证书。

本文额外需要用到的服务器代码在这里

注意,当模拟器中的端口无法联通的时候,尝试使用adb reverse来做端口转发。

10.1 WebView

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".WebviewActivity">
    <LinearLayout
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <EditText
            android:id="@+id/urlInput"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"/>
        <Button
            android:id="@+id/go"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="查询"/>
    </LinearLayout>
    <WebView
        android:id="@+id/webView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"/>
</LinearLayout>

布局文件

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.webkit.WebViewClient
import kotlinx.android.synthetic.main.activity_webview.*

class WebviewActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_webview)
        webView.settings.javaScriptEnabled = true
        webView.webViewClient = WebViewClient()
        webView.loadUrl("https://www.baidu.com")
        go.setOnClickListener {
            val url = urlInput.text.toString()
            if( url.isNotEmpty() ){
                webView.loadUrl(url)
            }
        }
    }
}

也比较简单的,使用WebView前要配置好settings和WebViewClient

10.2 Gson

implementation 'com.google.code.gson:gson:2.8.9'

加入gson依赖

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import com.google.gson.reflect.TypeToken
import java.lang.Exception
import kotlin.properties.Delegates

data class Person(val name:String,val age:Int)

class GsonActivity : AppCompatActivity() {

    val gson:Gson

    init{
        val gsonBuilder = GsonBuilder()
            .serializeNulls()
            .disableHtmlEscaping()
            .setDateFormat("yyyy-MM-dd HH:mm:ss")//对时间的序列化格式处理
        gson = gsonBuilder.create()
    }

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

    data class MyDataRaw(val code:Int,val data:JsonObject)

    private fun testRawJson(){
        val data = "{\"code\":123,\"data\":{\"name\":\"fish\",\"age\":123}}"
        val typeOf = object: TypeToken<MyDataRaw>(){}.type
        val dataRaw = gson.fromJson<MyDataRaw>(data,typeOf)
        Log.d("rawJson",dataRaw.toString())
        /*
        输出结果:
        MyDataRaw(code=123, data={"name":"fish","age":123})
         */
        val typeOf2 = object :TypeToken<Person>(){}.type
        val person = gson.fromJson<Person>(dataRaw.data,typeOf2)
        Log.d("rawJson2",person.toString())
        /*
        输出结果:
        Person(name=fish, age=123)
         */
    }

    data class MyDataNull(val name:String,val age:Int,val address:String)

    private fun testNullData(){
        val data = "{\"name\":\"fish2\",\"age\":567,\"address\":null}"
        val typeOf = object :TypeToken<MyDataNull>(){}.type
        val dataNull = gson.fromJson<MyDataNull>(data,typeOf)
        Log.d("nullJson",dataNull.toString())
        /*
        输出结果如下:
        MyDataNull(name=fish2, age=567, address=null)
         */
    }

    data class MyDataNull2(val name:String="fish",val age:Int=123,val address:String="mmk")

    private fun testNullData2(){
        val data = """
            {"age":123}
        """
        val typeOf = object :TypeToken<MyDataNull2>(){}.type
        val dataNull = gson.fromJson<MyDataNull2>(data,typeOf)
        Log.d("nullJson2",dataNull.toString())
        /*
        输出结果如下:
        MyDataNull(name=fish2, age=567, address=null)
         */
    }

    data class MyDataNull3(val name:String,val age:Int,val address:String)

    private fun testNullData3(){
        val data = """
            {"age":123,"address":"dd"}
        """
        val gson = GsonBuilder()
            .registerTypeAdapterFactory(KotlinAdapterFactory())
            .serializeNulls()
            .disableHtmlEscaping()
            .setDateFormat("yyyy-MM-dd HH:mm:ss")
            .create()
        val typeOf = object :TypeToken<MyDataNull3>(){}.type
        try{
            val dataNull = gson.fromJson<MyDataNull3>(data,typeOf)
            Log.d("nullJson3",dataNull.toString())
        }catch(e:Exception){
            e.printStackTrace()
        }
        /*
        输出结果如下:
        com.google.gson.JsonParseException: Field: 'name' in Class 'com.example.myapplication.GsonActivity$MyDataNull3' is marked nonnull but found null value
            2021-12-31 13:09:25.337 27841-27841/com.example.myapplication W/System.err:     at com.example.myapplication.KotlinAdapter.nullCheck(KotlinAdapter.kt:53)
            2021-12-31 13:09:25.338 27841-27841/com.example.myapplication W/System.err:     at com.example.myapplication.KotlinAdapter.read(KotlinAdapter.kt:41)
         */
    }

    private fun testGsonDeseiralize(){
        val data = "[{\"name\":\"Tom\",\"age\":20},{\"name\":\"Jack\",\"age\":25},{\"name\":\"Lily\",\"age\":22}]"
        val typeOf = object: TypeToken&lt;List&lt;Person&gt;&gt;(){}.type
        val personList = gson.fromJson&lt;List&lt;Person&gt;&gt;(data,typeOf)
        Log.d("personList","list "+personList.toString())
        /*
        输出结果:
        list [Person(name=Tom, age=20), Person(name=Jack, age=25), Person(name=Lily, age=22)]
         */
    }

    private fun testGsonSerialize(){
        val list = mutableListOf&lt;Person&gt;()
        list.add( Person("fish",123))
        list.add( Person("cat",456))
        list.add( Person("dog",789))
        val jsonStr = gson.toJson(list)
        Log.d("personList","json "+jsonStr)
        /*
        输出结果:
        json [{"age":123,"name":"fish"},{"age":456,"name":"cat"},{"age":789,"name":"dog"}]
         */
    }

    private fun testGson(){
        testGsonDeseiralize()
        testGsonSerialize()
        testRawJson()
        testNullData()
        testNullData2()
        testNullData3()
    }
}

gson的序列化和反序列化,套路和JackJson一样的了,反序列化的时候都需要传入一个匿名对象。要点如下:

  • gsonBuilder的配置,注意时间字段的配置
  • TypeToken作为泛型的反序列化工具
  • JsonObject和JsonArray作为RawJson的工具
  • gson对Kotlin的非空类型标注是没有理会的,即使这个类型的非空的,依然将null数据设置进去
implementation "org.jetbrains.kotlin:kotlin-reflect:1.6.10"

加入反射库

package com.example.myapplication

import com.google.gson.Gson
import com.google.gson.JsonParseException
import com.google.gson.TypeAdapter
import com.google.gson.TypeAdapterFactory
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
import kotlin.reflect.KClass
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.jvm.isAccessible

class KotlinAdapterFactory : TypeAdapterFactory {

    private fun Class&lt;*&gt;.isKotlinClass(): Boolean {
        return this.declaredAnnotations.any {
            // 只关心 kt 类型
            it.annotationClass.qualifiedName == "kotlin.Metadata"
        }
    }

    override fun &lt;T : Any&gt; create(gson: Gson, type: TypeToken&lt;T&gt;): TypeAdapter&lt;T&gt;? {
        return if (type.rawType.isKotlinClass()) {
            val kClass = (type.rawType as Class&lt;*&gt;).kotlin
            val delegateAdapter = gson.getDelegateAdapter(this, type)
            KotlinAdapter&lt;T&gt;(delegateAdapter, kClass as KClass&lt;T&gt;)
        } else {
            null
        }
    }
}

class KotlinAdapter<T : Any&gt;(
    private val delegateAdapter: TypeAdapter&lt;T&gt;,
    private val kClass: KClass&lt;T&gt;
) : TypeAdapter&lt;T&gt;() {

    override fun read(input: JsonReader?): T? {
        return delegateAdapter.read(input)?.apply {
            nullCheck(this)
        }
    }

    override fun write(out: JsonWriter?, value: T) {
        delegateAdapter.write(out, value)
    }

    private fun nullCheck(value: T) {
        kClass.declaredMemberProperties.forEach { prop -&gt;
            prop.isAccessible = true
            if (!prop.returnType.isMarkedNullable && prop(value) == null)
                throw JsonParseException(
                    "Field: '${prop.name}' in Class '${kClass.java.name}' is marked nonnull but found null value"
                )
        }
    }
}

实现KotlinAdapterFactory。具体原理看这里

gson对Kotlin的Null检查的方法有两种:

  • 对data class的每个字段都加入默认值,注意是每个字段,否则依然会出问题。
  • 额外加入kotlin反射库,大概增加2MB大小,在运行时读取kotlin的反射信息,动态检查是否满足Null约束

10.3 OkHttp

implementation 'com.squareup.okhttp3:okhttp:4.9.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'

加入OkHttp依赖,以及Kotlin协程依赖

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".OkHttpActivity">
    <Button
        android:id="@+id/testGo"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="go接口"/>
    <Button
        android:id="@+id/testGet"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="get带query接口"/>
    <Button
        android:id="@+id/testPost"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="post带body接口"/>
    <TextView
        android:id="@+id/showText"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"/>
</LinearLayout>

布局文件

package com.example.myapplication.okhttp

import android.util.Log
import com.example.myapplication.Person
import com.example.myapplication.service.Country
import com.example.myapplication.service.Result
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.Response
import retrofit2.http.Header
import retrofit2.http.Query
import java.io.IOException
import java.lang.RuntimeException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

object OkHttpService {
    val client = OkHttpClient()

    val gson: Gson

    init{
        val gsonBuilder = GsonBuilder()
        gsonBuilder.serializeNulls()
        gsonBuilder.disableHtmlEscaping()
        gson = gsonBuilder.create()
    }

    //注意,这里需要用inline+reified
    inline suspend fun &lt;reified T&gt; Call.await():T{
        val typeOf = object: TypeToken&lt;T&gt;(){}.type
        return suspendCoroutine { continuation -&gt;
            enqueue(object: Callback {
                override fun onResponse(call: Call, response: okhttp3.Response) {
                    val bodyStr = response.body?.string()
                    if( bodyStr != null ){
                        Log.d("OkHttp",bodyStr)
                        val body = gson.fromJson&lt;T&gt;(bodyStr,typeOf)
                        continuation.resume(body)
                    }else{
                        continuation.resumeWithException(RuntimeException("response body is null"))
                    }
                }

                override fun onFailure(call: Call, e: IOException) {
                    continuation.resumeWithException(e)
                }
            })
        }
    }

    fun get1():Call{
        val request = Request.Builder()
            .url("http://192.168.1.68:8585/hello/get1")
            .get()
            .build()
        return client.newCall(request)
    }

    suspend  fun get2(user:String, myHeader: String):Result&lt;String?&gt;{
        var url = HttpUrl.Builder()
            .scheme("http")
            .host("192.168.1.68")
            .port(8585)
                //不能合并多个segments,不能这样写/hello/get2
            .addPathSegment("hello")
            .addPathSegment("get2")
            .addQueryParameter("user", user)
            .build();

        Log.d("OkHttp","get2 url ${url}")


        val request = Request.Builder()
            .url(url)
            .get()
            .addHeader("my",myHeader)
            .build()

        return client.newCall(request).await()
    }

    suspend  fun post1(country: Country, myHeader: String):Result<String?>{
        var url = HttpUrl.Builder()
            .scheme("http")
            .host("192.168.1.68")
            .port(8585)
            .addPathSegment("hello")
            .addPathSegment("post1")
            .build();

        val body = gson.toJson(country)

        val requestBody = body.toRequestBody("application/json; charset=utf-8".toMediaType())

        val request = Request.Builder()
            .url(url)
            .post(requestBody)
            .addHeader("my",myHeader)
            .build()

        return client.newCall(request).await()
    }
}

OkHttpService,发送HTTP请求的话,有点麻烦呀,这样就好。这里我们加入一个await扩展方法,方便协程使用,而且enqueue的返回值会自动切换到调用的线程上。

  • Query参数用HttpUrl.Builder来创建
  • Header参数用Request.Builder来创建
  • Body参数,用String的扩展方法toRequestBody,或者用FormBody.Builder来创建,然后传入到body参数里面就可以了
package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.myapplication.okhttp.OkHttpService
import com.example.myapplication.service.Country
import com.example.myapplication.service.Result
import com.example.myapplication.service.RetrofitService
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import kotlinx.android.synthetic.main.activity_ok_http.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import java.io.IOException
import java.lang.Exception

class OkHttpActivity : AppCompatActivity() {
    val gson: Gson

    init{
        val gsonBuilder = GsonBuilder()
        gsonBuilder.serializeNulls()
        gsonBuilder.disableHtmlEscaping()
        gson = gsonBuilder.create()
    }

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

        testGo.setOnClickListener {
            //OkHttp不会自动跳转到UI线程
            OkHttpService.get1().enqueue(object: Callback {
                override fun onResponse(
                    call: Call,
                    response: Response
                ) {
                    val data= response.body?.string()
                    runOnUiThread {
                        showText.text =data
                    }
                }

                override fun onFailure(call: Call, e: IOException) {
                    runOnUiThread {
                        showText.text = "failure! " + e.message
                    }
                }
            })
        }

        //协程写法
        testGet.setOnClickListener {
            GlobalScope.launch(Dispatchers.Main) {
                try{
                    val data = OkHttpService.get2("dog","mk")
                    showText.text = gson.toJson(data)
                }catch(e: Exception){
                    showText.text = "failure! "+ e.message
                }
            }
        }

        //协程写法
        testPost.setOnClickListener {
            GlobalScope.launch(Dispatchers.Main) {
                try{
                    val country = Country("China", listOf(
                        Country.Person("sheep",123),
                        Country.Person("rat",456)))
                    val data = OkHttpService.post1(country,"m2")
                    showText.text = gson.toJson(data)
                }catch(e: Exception){
                    showText.text = "failure! "+ e.message
                }
            }
        }
    }
}

这是调用OkHttp的写法,明显协程要简单省事得多。注意,为了简单起见,这里用了GlobalScope,而不是正确的CoroutineScope。

10.4 Retrofit

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'

加入retrofit和协程依赖。retrofit是OkHttp的进一步封装,使用了类似MyBatis那种将接口自动转换为实现的套路。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".RetrofitActivity">
    <Button
        android:id="@+id/testGo"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="go接口"/>
    <Button
        android:id="@+id/testGet"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="get带query接口"/>
    <Button
        android:id="@+id/testPost"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="post带body接口"/>
    <TextView
        android:id="@+id/showText"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"/>
</LinearLayout>

先定义一个布局文件

package com.example.myapplication.service

import retrofit2.Call
import retrofit2.http.*

data class Country(val name:String,val persons:List<Person>){
    data class Person(val name:String,val age:Int)
}
interface AppService {
    @GET("/hello/get1")
    fun get1():Call<Result<String?>>

    @GET("/hello/get2")
    fun get2(@Query("user")user:String,@Header("my") myHeader: String):Call<Result<String?>>

    @POST("/hello/post1")
    fun post1(@Body data:Country, @Header("my") myHeader: String):Call<Result<String?>>
}

先定义一个接口,简单明了

package com.example.myapplication.service;

import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory
import java.lang.RuntimeException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

object RetrofitService {
    val retrofit = Retrofit.Builder()
        .baseUrl("http://192.168.1.68:8585/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    val appService = retrofit.create(AppService::class.java)

    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)
                }
            })
        }
    }
}

然后定义一个RetrofitService

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.myapplication.service.Country
import com.example.myapplication.service.Result
import com.example.myapplication.service.RetrofitService
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import kotlinx.android.synthetic.main.activity_retrofit.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import retrofit2.await
import java.lang.Exception

class RetrofitActivity : AppCompatActivity() {
    val gson:Gson
    init{
        val gsonBuilder = GsonBuilder()
        gsonBuilder.serializeNulls()
        gsonBuilder.disableHtmlEscaping()
        gson = gsonBuilder.create()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_retrofit)
        testGo.setOnClickListener {
            RetrofitService.appService.get1().enqueue(object:Callback<Result<String?>>{
                override fun onResponse(
                    call: Call<Result<String?>>,
                    response: Response<Result<String?>>
                ) {
                    val data= response.body()
                    showText.text = gson.toJson(data)
                }

                override fun onFailure(call: Call<Result<String?>>, t: Throwable) {
                    showText.text ="failure! "+t.message
                }
            })
        }

        //协程写法
        testGet.setOnClickListener {
            GlobalScope.launch(Dispatchers.Main) {
                try{
                    val data = RetrofitService.appService.get2("fish","mm").await()
                    showText.text = gson.toJson(data)
                }catch(e:Exception){
                    showText.text = "failure! "+ e.message
                }
            }
        }

        //协程写法
        testPost.setOnClickListener {
            GlobalScope.launch(Dispatchers.Main) {
                try{
                    val country = Country("China", listOf(Country.Person("fish",123),Country.Person("cat",456)))
                    val data = RetrofitService.appService.post1(country,"m2").await()
                    showText.text = gson.toJson(data)
                }catch(e:Exception){
                    showText.text = "failure! "+ e.message
                }
            }
        }

    }
}

最后,直接调用就可以了,确实比OkHttp更加省事简单。缺点就是可控性要差一点,例如需要Retrofit的所有接口新增一个Header参数,或者自定义Query参数的格式转换等等。

10.5 OkHttpUtil

package com.balefcloud.scancode.network

import com.balefcloud.scancode.service.LoginService
import com.balefcloud.scancode.util.MyGson
import com.google.gson.JsonElement
import com.google.gson.reflect.TypeToken
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.lang.RuntimeException
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

object OkHttpUtil {
    data class BusinessException(val code:Int,val msg:String):RuntimeException(msg)

    data class NetworkException(val msg:String,val msgCause:Throwable?):RuntimeException(msgCause)

    data class LogoutException(val code:Int,val msg:String):RuntimeException(msg)

    data class ResponseFormat(val code:Int,val msg:String,val data:JsonElement)

    data class HeaderAndBody(val cookie: Map<String,String>,val body:String)

    //注意,这里需要用inline+reified
    suspend fun Call.await():HeaderAndBody{
        return suspendCoroutine { continuation ->
            enqueue(object: Callback {
                private fun extractCookie(cookieValue:String,cookieMap:HashMap<String,String>){
                    val cookieInfo = cookieValue.split(";")
                    for( cookieSingleInfo in cookieInfo ){
                        val cookieNameAndValueInfo = cookieSingleInfo.split("=")
                        if( cookieNameAndValueInfo.size != 2 ){
                            continue
                        }
                        val name = cookieNameAndValueInfo[0].trim()
                        val value = cookieNameAndValueInfo[1].trim()
                        if( name.isNotBlank() ){
                            cookieMap[name] = value
                        }
                    }
                }
                private fun extractHeader(response:Response):Map<String,String>{
                    val headers = response.headers
                    val cookieMap = HashMap<String,String>()
                    for( (key,value) in headers ){
                        if( key == "Set-Cookie"){
                            extractCookie(value,cookieMap)
                        }
                    }
                    return cookieMap
                }
                override fun onResponse(call: Call, response: Response) {
                    if(response.code != 200 ){
                        continuation.resumeWithException(NetworkException("网络返回码不是200,是${response.code}",null))
                    }else{
                        val cookie = this.extractHeader(response)
                        val bodyStr = response.body?.string()
                        if( bodyStr != null ){
                            continuation.resume(HeaderAndBody(cookie,bodyStr))
                        }else{
                            continuation.resumeWithException(NetworkException("返回的body为空",null))
                        }
                    }

                }

                override fun onFailure(call: Call, e: IOException) {
                    continuation.resumeWithException(NetworkException("网络错误",e))
                }
            })
        }
    }

    fun buildHttpClient():OkHttpClient{
        return OkHttpClient.Builder()
            .connectTimeout(5, TimeUnit.SECONDS)
            .readTimeout(5,TimeUnit.SECONDS)
            .writeTimeout(5,TimeUnit.SECONDS)
            .build()
    }

    fun buildUrl(path:String,data:String):HttpUrl{
        val pathSegments = path.split("/")

        var urlBuilder = HttpUrl.Builder()
            .scheme("https")
            .host("furniture.fishedee.com")
        for( single in pathSegments ){
            urlBuilder = urlBuilder.addPathSegment(single)
        }
        if( data.isNotBlank() ){
            urlBuilder = urlBuilder.addQueryParameter("data",data)
        }
        return urlBuilder.build()
    }

    suspend fun buildHeader(builder:Request.Builder):Request.Builder{
        val sessionId = LoginService.getSession()
        val rememberMeToken = LoginService.getRememberMeToken()
        val xsrfInfo = LoginService.getXsrfToken()
        return builder
            .addHeader("X-XSRF-TOKEN",xsrfInfo)
            .addHeader("Cookie","JSESSIONID=${sessionId}; XSRF-TOKEN=${xsrfInfo}; remember-me=${rememberMeToken}")
    }

    suspend fun writeCookie(cookieMap:Map<String,String>){
        val session = cookieMap["JSESSIONID"]
        val rememberMe = cookieMap["remember-me"]
        val xsrfToken = cookieMap["XSRF-TOKEN"]
        if( session != null ){
            LoginService.setSession(session)
        }
        if( rememberMe != null ){
            LoginService.setRememberMeToken(rememberMe)
        }
        if( xsrfToken != null ){
            LoginService.setXsrfToken(xsrfToken)
        }
    }

    fun extractBody(body:String):JsonElement{
        val typeOf = object: TypeToken<ResponseFormat>(){}.type
        val body = MyGson.gson.fromJson<ResponseFormat>(body,typeOf)
        if( body.code != 0 ){
            if( body.code == 20001 ){
                throw LogoutException(body.code,body.msg)
            }else{
                throw BusinessException(body.code,body.msg)
            }
        }
        return body.data
    }

    suspend inline fun<reified T,reified U> get(path:String,data:T):U{
        val requestData = MyGson.gson.toJson(data)
        val url = buildUrl(path,requestData)
        val request = Request.Builder()
            .url(url)
            .get()
        buildHeader(request)
        val response = buildHttpClient().newCall(request.build()).await()
        this.writeCookie(response.cookie)
        val realBody = this.extractBody(response.body)
        val typeOf = object: TypeToken<U>(){}.type
        return MyGson.gson.fromJson<U>(realBody,typeOf)
    }

    suspend inline fun<reified T,reified U> post(path:String,data:T):U{
        val requestData = MyGson.gson.toJson(data)
        val requestBody = requestData.toRequestBody("application/json; charset=utf-8".toMediaType())
        val url = buildUrl(path,"")
        val request = Request.Builder()
            .url(url)
            .post(requestBody)
        buildHeader(request)
        val response = buildHttpClient().newCall(request.build()).await()
        this.writeCookie(response.cookie)
        val realBody = this.extractBody(response.body)
        val typeOf = object: TypeToken<U>(){}.type
        return MyGson.gson.fromJson<U>(realBody,typeOf)
    }

    suspend inline fun<reified U> postForm(path:String,data:Map<String,String>):U{
        val formBodyBuilder = FormBody.Builder()
        for( (key,value) in data ){
            formBodyBuilder.add(key,value)
        }
        val url = buildUrl(path,"")
        val request = Request.Builder()
            .url(url)
            .post(formBodyBuilder.build())
        buildHeader(request)
        val response = buildHttpClient().newCall(request.build()).await()
        this.writeCookie(response.cookie)
        val realBody = this.extractBody(response.body)
        val typeOf = object: TypeToken<U>(){}.type
        return MyGson.gson.fromJson<U>(realBody,typeOf)
    }
}

包装了一个OkHttp的工具,没啥好说的,都挺简单的

11 Material Design

没有用到,暂时不做笔记

12 Jetpack

12.1 ViewModel

代码在这里

ViewModel很好地解决,业务逻辑应该放哪里的问题。ViewModel的生命周期更长,更容易被掌握

12.1.1 依赖

implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
implementation 'androidx.activity:activity-ktx:1.4.0'

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'

加入ViewModel和协程依赖

12.1.2 无参数ViewModel

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ViewModelActivity1">
    <TextView
        android:id="@+id/showText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"/>
    <Button
        android:id="@+id/plusOne"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="加1"/>
</LinearLayout>

页面布局

package com.example.myapplication

import androidx.lifecycle.ViewModel

class MyViewModel1 :ViewModel(){
    var counter = 0

    override fun onCleared() {
        super.onCleared()

        //ViewModel唯一的生命周期,清除
        counter = 0
    }
}

创建一个ViewModel

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.lifecycle.ViewModelProvider
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.activity_view_model1.*

class ViewModelActivity1 : AppCompatActivity() {
    lateinit var viewModel: MyViewModel1

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

        //创建无参数的ViewModel
        viewModel = ViewModelProvider(this, ViewModelProvider.NewInstanceFactory()).get(MyViewModel1::class.java)

        plusOne.setOnClickListener {
            viewModel.counter++
            showText.text = viewModel.counter.toString()
        }

        showText.text = viewModel.counter.toString()
    }
}

使用ViewModelProvider直接获取这个ViewModel,对这个ViewModel里面的value进行读写操作

12.1.3 有参数ViewModel

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ViewModelActivity2">
    <TextView
        android:id="@+id/showText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"/>
    <Button
        android:id="@+id/plusOne"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="加1"/>
</LinearLayout>

布局文件

package com.example.myapplication

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider


class MyViewModel2(initCounter:Int) : ViewModel(){
    private var counter = initCounter

    override fun onCleared() {
        super.onCleared()

        //ViewModel唯一的生命周期,清除
        counter = 0
    }

    fun inc(){
        this.counter++
    }

    fun get():Int{
        return this.counter
    }

    class Factory(private val initCounter:Int):ViewModelProvider.Factory{
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            return MyViewModel2(initCounter) as T
        }
    }
}

创建一个带有初始参数的ViewModel,需要附带一个Factory

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.lifecycle.ViewModelProvider
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.activity_view_model1.*
import kotlinx.android.synthetic.main.activity_view_model2.*
import kotlinx.android.synthetic.main.activity_view_model2.plusOne
import kotlinx.android.synthetic.main.activity_view_model2.showText

class ViewModelActivity2 : AppCompatActivity() {
    private lateinit var viewModel:MyViewModel2

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


        //创建含参数的ViewModel
        viewModel = ViewModelProvider(this, MyViewModel2.Factory(100)).get(MyViewModel2::class.java)

        plusOne.setOnClickListener {
            viewModel.inc()
            showText.text = viewModel.get().toString()
        }

        showText.text = viewModel.get().toString()
    }
}

ViewModelProvider的第二个参数需要传入我们的Factory,其他不变

12.1.4 协程的ViewModel

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ViewModelActivity3">
    <TextView
        android:id="@+id/showText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"/>
    <Button
        android:id="@+id/plusOne"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="加1"/>
    <Button
        android:id="@+id/cancel"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="取消"/>
</LinearLayout>

布局文件

package com.example.myapplication

import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*

class MyTimeout{
    fun wait(timeout:Long,callback:(target:Int)->Unit):Thread{
        val thread = Thread{
            try{
                Thread.sleep(timeout)
                callback(100)
            }catch(e:InterruptedException){
                Log.d("ViewModel","catch thread interrupt")
            }
        }
        thread.start()
        return thread
    }
}

suspend fun suspendCoroutine_1():Int{
    //注意改为了suspendCancellableCoroutine,而不是suspendCoroutine
    return suspendCancellableCoroutine{ continuation->
        val thread = MyTimeout().wait(5000) { result ->
            continuation.resume(result){
                //resume的时候发现协程取消了
                //do nothing
            }
        }
        //关键是这一句,invokeOnCancellation的意义是,当收到协程cancel的时候,得到额外的通知
        //这样可以快速停止普通作用域的操作,例如及时停止线程池中对网络请求的发送(OkHttp),释放回调避免对View的内存占用(AutoDispose),
        continuation.invokeOnCancellation {
            //通知线程interrupt
            Log.d("ViewModel","cancel!!!")
            thread.interrupt()
        }

    }
}


class MyViewModel3 :ViewModel(){
    var counter = 0
    var job:Job? = null

    suspend fun inc(){
        this.job?.cancel()
        this.job = viewModelScope.launch {
            suspendCoroutine_1()
            counter++
        }
        this.job?.join()
    }

    fun cancel(){
        //一旦取消,ViewModelScope不再响应任何launch
        //viewModelScope.cancel()

        this.job?.cancel()
    }

    fun get():Int{
        return this.counter
    }
}

ViewModel自带了一个viewModelScope的协程作用域,这个作用域在ViewModel的onClear生命周期会自动被cancel。

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.ViewModelProvider
import kotlinx.android.synthetic.main.activity_view_model2.*
import kotlinx.android.synthetic.main.activity_view_model2.plusOne
import kotlinx.android.synthetic.main.activity_view_model2.showText
import kotlinx.android.synthetic.main.activity_view_model3.*
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch

class ViewModelActivity3 : AppCompatActivity() {

    val mainScope = MainScope()

    lateinit var viewModel: MyViewModel3

    override fun onDestroy() {
        super.onDestroy()
        //退出的时候,记得cancel当前的scope
        mainScope.cancel()
    }

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


        //创建无参数的ViewModel
        viewModel = ViewModelProvider(this, ViewModelProvider.NewInstanceFactory()).get(MyViewModel3::class.java)

        plusOne.setOnClickListener {
            mainScope.launch {
                Log.d("ViewModel","pre inc")
                viewModel.inc()
                Log.d("ViewModel","post inc ${viewModel.get()}")
                showText.text = viewModel.get().toString()
            }
        }
        cancel.setOnClickListener {
            mainScope.launch {
                viewModel.cancel()
            }
        }

        showText.text = viewModel.get().toString()
    }
}

但是,一般情况下,我们更建议用Activity的MainScope,这个MainScope是由kotlinx-coroutines-android库提供的,注意,这里在Activity的onDestroy的时候需要手动cancel。

12.1.5 官方推荐做法

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

    <TextView
        android:id="@+id/showText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"/>
    <Button
        android:id="@+id/plusOne"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="加1"/>
    <Button
        android:id="@+id/minusOne"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="减1"/>

</LinearLayout>

布局文件

package com.example.myapplication.new_recommend

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.receiveAsFlow

class MyViewModel4 :ViewModel(){
    //ui状态
    private val _counter = MutableStateFlow&lt;Int&gt;(0)

    val counter:StateFlow&lt;Int&gt; =_counter

    //副作用
    private val _effect:Channel&lt;String&gt; = Channel()

    val effect = _effect.receiveAsFlow().buffer()

    fun inc(){
        viewModelScope.launch {
            delay(100)
            _counter.value++
        }
    }

    fun minus(){
        viewModelScope.launch {
            delay(100)
            _counter.value--
            _effect.send("减去1了")
        }
    }

    fun setInitValue(data:Int){
        _counter.value = data
    }
}

使用stateFlow来传送UI状态,使用Channel传送副作用。而且,ViewModel的所有方法都尽可能不是suspend的,由ViewModel来启动协程。

注意,receiveAsFlow有一个buffer参数,避免发送端的堵塞。

package com.example.myapplication.new_recommend

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.whenStarted
import com.example.myapplication.R
import kotlinx.android.synthetic.main.activity_view_model4.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch

class ViewModelActivity4 : AppCompatActivity() {

    //嵌入一个viewModel
    private val viewModel: MyViewModel4 by viewModels()

    init {
        lifecycleScope.launch {
            //当counter变化的时候,通知showText变化
            //为什么要用repeatOnLifecycle
            //因为我们希望UI在不可见的时候,不要进行更新,提高性能以及避免崩溃
            //repeatOnLifecycle(Lifecycle.State.STARTED)的意思:
            //每次Activity进入Started的时候就会执行,Activity退出Started的时候就会自动cancel
            //可重启生命周期感知型协程,注意这个函数是阻塞的,不会返回回来
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                Log.d("ViewModel4", "init collect")
                viewModel.counter.collect { v -&gt;
                    Log.d("ViewModel4", "have data ${v}")
                    showText.text = v.toString()
                }
            }
        }

        lifecycleScope.launch {
            whenStarted{
                viewModel.effect.collect {
                    Toast.makeText(this@ViewModelActivity4,it,Toast.LENGTH_SHORT).show()