安卓 Kotlin 高级教程(四)
十、开发
与前几章相比,本章涉及的问题更接近于开发问题。我们将在这里讨论的主题与特定的 Android 操作系统 API 没有太紧密的联系。我们更关心的是如何使用 Kotlin 方法最好地完成技术需求。
这一章还有一节介绍了如何将 Kotlin 代码转换成可以服务于WebView小部件的 JavaScript 代码。
用 Kotlin 编写可重用库
你在网上找到的大多数教程都是关于活动、服务、广播接收器和内容供应器的。这些组件是可重用的,因为您可以或多或少地从一个项目中提取它们,并将它们复制到另一个项目中。Android 操作系统中的封装已经达到了一个复杂的阶段,这使得重用成为可能。然而,在较低的层面上,在某些情况下,Android 中提供的库或 API 可能不适合您的所有需求,因此您可能会尝试自己开发这样的库,然后在可行的情况下将源代码从一个项目复制到另一个项目。
当然,这种在源代码层面上的复制并不适合可重用库的现代方法;只需考虑引入大量样板工作的维护和版本控制问题。最好的事情是将这样的可重用库设计为专用的开发工件。然后,它们可以很容易地从不同的项目中重用。
在接下来的部分中,我们将开发一个基本的正则表达式库,作为您自己的库项目的概念基础。
启动库模块
库项目是包含一个或多个模块的项目。在 Android Studio 打开的情况下,创建一个新项目,并确保它启用了 Kotlin 支持。然后,在新项目中进入新➤新模块,选择 Android 库。
注意
Android 库不仅仅是类的集合。它还可能包含资源和配置文件。出于我们的目的,我们将只看类。从开发的角度来看,这些额外的可能性没有坏处,你可以忽略它们。然而,对于使用 Android 库类型的项目,与只使用 JAR 文件相比,这为将来的扩展提供了更多的可能性。
创建库
在库模块中,创建一个新的 Kotlin 类,并在其中编写以下内容:
package com.example.regularexpressionlib
infix operator fun String.div(re:String) :
Array<MatchResult> =
Regex(re).findAll(this).toList().toTypedArray()
infix operator fun String.rem(re:String) :
MatchResult? =
Regex(re).findAll(this).toList().firstOrNull()
operator fun MatchResult.get(i:Int) =
this.groupValues[i]
fun String.onMatch(re:String, func: (String)-> Unit)
: Boolean =
this.matches(Regex(re)).also { if(it) func(this) }
这四个操作符和函数的作用不亚于允许我们编写searchString/regExpString来搜索正则表达式匹配和searchString % regExpString来搜索第一个匹配。此外,我们可以使用searchString.onMatch()让一些块只有在匹配时才执行。
这个列表不同于我们在本书中看到的所有列表。首先,你可以看到我们这里没有任何类。这是可能的,因为 Kotlin 知道文件工件的概念。在幕后,它根据包名生成一个隐藏的类。通过import com.example.regularexpressionlib.*导入的库的任何客户端都可以像在 Java 中执行所有这些函数的静态导入一样工作。
infix operator fun String.div( re:String )定义字符串的除法运算符。这样的划分在标准中是不可能的,所以与 Kotlin 内置运算符没有冲突。它使用 Kotlin 库中的Regex类来查找搜索字符串中正则表达式的所有匹配项,并将其转换为数组,因此我们可以稍后使用[ ]操作符通过索引来访问结果。infix operator fun String.rem( re:String )做了几乎相同的事情,但是它为字符串定义了%操作符,执行正则表达式搜索,并且只获取第一个结果,如果没有结果,则返回null。
operator fun MatchResult.get(i:Int) = ...是前面运算符返回的MatchResult的扩展。它允许通过索引访问匹配的组。比方说,如果你搜索 (el)【内侧" Hello Nelo" ,你可以写("Hello Nelo" / "(e.)") [0][1]来获得第一组的第一场比赛,在这种情况下 el 来自 Hello
测试库
我们需要一种在开发库的同时测试它的方法。不幸的是,Android Studio 3.0 不允许类似于main()的功能。我们唯一能做的就是创建一个单元测试,对于我们的例子,这样的单元测试可以如下所示:
import org.junit.Assert.*
import org.junit.Test
...
class RegularExpressionTest {
@Test
fun proof_of_concept() {
assertEquals(1, ("Hello Nelo" / ".*el.*").size)
assertEquals(2, ("Hello Nelo" / ".*?el.*?").size)
var s1:String = ""
("Hello Nelo" / "e[lX]").forEach {
s1 += it.groupValues
}
assertEquals("[el][el]", s1)
var s2: String = ""
("Hello Nelo" / ".*el.*").firstOrNull()?.run {
s2 += this[0]
}
assertEquals("Hello Nelo", s2)
assertEquals("el",
("Hello Nelo" % ".*(el).*")?.let{ it[1] } )
assertEquals("el",
("Hello Nelo" / ".*(el).*")[0][1])
var match1: Boolean = false
"Hello".onMatch(".*el.*") {
match1 = true
}
assertTrue(match1)
}
}
然后,您可以使用 Android Studio 的上下文菜单像运行任何其他单元测试一样运行这个测试。注意,在开发的早期阶段,您可以向测试添加println()语句,以便在测试运行时在测试控制台上打印一些信息。
使用图书馆
一旦你调用构建➤重建项目,你可以在模块的这个文件夹中找到 Android 库。
build/outputs/aar
要从客户端使用它,请通过“新建➤新模块”在客户端项目中创建新模块,然后选择“导入”。JAR/。AAR 包,并导航到库项目生成的.aar文件。
警告
该程序复制.aar文件。如果您有一个新版本的库,您可以删除客户端项目中的库项目并再次导入它,或者手动将.aar文件从库项目复制到客户端项目。
要在客户机中使用这个库,您只需将import com.example.regularexpressionlib.*添加到头部,此后您就可以应用新的匹配结构,如前面的测试所示。
发布图书馆
到目前为止,我们一直在本地使用这个库,这意味着您在开发机器上的某个地方有这个库项目,并且可以从同一台机器上的其他项目中使用它。您还可以发布库,这意味着如果您手头有一个企业存储库,就可以让企业环境中的其他开发人员使用它们,或者让它们对您想要提供给社区的库真正公开。
不幸的是,发布库的过程相当复杂,包括在几个地方修改构建文件,以及使用第三方插件和存储库网站。这使得发布库的过程变得复杂而脆弱,当您阅读本书时,对一个可能的发布过程的详细描述可能很容易过时。因此,我要求你自己做研究。在你最喜欢的搜索引擎中输入publishing Android libraries,你会很容易地找到对你有帮助的在线资源。如果您发现几个过程可能适合您的需要,一般的经验法则是使用一个有大的支持社区并且尽可能简单的过程。
此外,对于公司项目,如果您想使用公共存储库,请确保您有权限使用它们。如果您不能使用公共存储库,安装一个公司存储库并不是一个过于复杂的任务。要建立公司的 Maven 资源库,您可以使用软件套件 Artifactory。
使用 Kotlin 的高级监听器
无论你在为 Android 开发什么样的应用,在某个地方或者更常见的地方,你都必须为 API 函数调用提供监听器。在 Java 中,您必须创建实现监听器接口的类或匿名内部类,而在 Kotlin 中,您可以更优雅地做到这一点。
如果你有一个单一的抽象方法(SAM)类或接口,这很容易。例如,如果你想给一个按钮添加一个点击监听器,这意味着你必须提供一个接口View.OnClickListener的实现,用 Java 的方式来做就是这样的:
btn.setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View?) {
// do s.th.
}
})
然而,由于这个接口只有一个方法,您可以更简洁地编写它,就像这样:
btn.setOnClickListener {
// do s.th.
}
你可以让编译器找出接口方法应该如何实现。
如果侦听器不是 SAM,这意味着如果它有不止一个方法,这种简短的符号就不再可能。例如,如果您有一个EditText视图,并且想要添加一个文本更改监听器,那么即使您只对onTextChanged()回调方法感兴趣,您也必须编写下面的代码。
val et = ... // the EditText view
et.addTextChangedListener( object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
// ...
}
override fun beforeTextChanged(s: CharSequence?,
start: Int, count: Int, after: Int) {
// ...
}
override fun onTextChanged(s: CharSequence?,
start: Int, before: Int, count: Int) {
// ...
}
})
然而,您可以做的是在一个实用程序文件中扩展EditText类,并增加提供一个简化的文本更改监听器的可能性。为此,从这样一个文件开始,例如,com.example包内的utility.kt,当然也可以是你的应用的任何包。添加以下内容:
fun EditText.addTextChangedListener(l:
(CharSequence?, Int, Int, Int) -> Unit) {
this.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
}
override fun beforeTextChanged(s: CharSequence?,
start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?,
start: Int, before: Int, count: Int) {
l(s, start, before, count)
}
})
}
这将所需的方法动态添加到该类中。
现在,您可以在任何需要的地方使用import com.example.utility.*,然后编写下面的代码,与最初的构造相比,它看起来要简洁得多:
val et = ... // the EditText view
et.addTextChangedListener({ s: CharSequence?,
start: Int, before: Int, count: Int ->
// do s.th.
})
多线程操作
我们已经在第九章中讨论了多线程。在这一节中,我们只是指出 Kotlin 在语言层面上可以简化多线程。
Kotlin 在其标准库中包含了几个实用函数。与使用 Java API 相比,它们有助于更容易地启动线程和计时器;见表 10-1 。
表 10-1
科特林并发
|名字
|
因素
|
返回
|
描述
|
| --- | --- | --- | --- |
| fixedRate-Timer | name: String?``daemon: Boolean``initialDelay: Long``period: Long``action: TimerTask.() -> Unit | Timer | 为固定速率调度创建并启动计时器对象。period和initialDelay参数以毫秒为单位。 |
| fixedRate-Timer | name: String?``daemon: Boolean``startAt: Date``period: Long``action: TimerTask.() -> Unit | Timer | 为固定速率调度创建并启动计时器对象。period 参数以毫秒为单位。 |
| timer | name: String?``daemon: Boolean``initialDelay: Long``period: Long``action: TimerTask.() -> Unit | Timer | 为固定速率调度创建并启动计时器对象。period 参数是上一个任务结束和下一个任务开始之间的时间,以毫秒为单位。 |
| timer | name: String?``daemon: Boolean``startAt: Date``period: Long``action: TimerTask.() -> Unit | Timer | 为固定速率调度创建并启动计时器对象。period 参数是上一个任务结束和下一个任务开始之间的时间,以毫秒为单位。 |
| thread | start: Boolean``isDaemon: Boolean``contextClassLoader: ClassLoader?``name: String?``priority: Int``block: () -> Unit | Thread | 创建并可能启动一个线程,执行它的block。优先级较高的线程优先于优先级较低的线程执行。 |
对于定时器函数,action参数是一个闭包,其中this是对应的TimerTask对象。例如,使用它,你可以从它的执行块中取消定时器。将daemon或isDaemon设置为true的线程或定时器不会阻止 JVM 在所有非守护线程退出后关闭。
凭借其通用的功能,Kotlin 在帮助我们处理并发性方面做得很好;在java.util.concurrent中,许多处理并行执行的类都将Runnable或Callable作为参数,在 Kotlin 中,你总是可以通过直接的{ ... } lambda 构造来替换这样的 SAM 构造。这里有一个例子:
val es = Executors.newFixedThreadPool(10)
// ...
val future = es.submit({
Thread.sleep(2000L)
println("executor over")
10
} as ()->Int)
val res:Int = future.get()
因此,您不必像在 Java 中那样编写以下代码:
ExecutorService es = Executors.newFixedThreadPool(10);
// ...
Callable<Integer> c = new Callable<>() {
public Integer call() {
try {
Thread.sleep(2000L);
} catch(InterruptedException e { }
System.out.println("executor over");
return 10;
};
Future<Integer> f = es.submit(c);
int res = f.get();
注意,在 Kotlin 代码中,()->Int的造型是必要的,即使 Android Studio 抱怨这是多余的。这样做的原因是,如果我们不这样做,另一个带有参数Runnable的方法会被调用,而执行器无法返回值。
兼容性库
框架 API 和兼容性库之间有一个重要的区别,但在开始时并不容易理解。如果你开始开发 Android 应用,你会经常看到相同名称的类出现在不同的包中。或者更糟的是,您会看到不同包中不同名称的类似乎在做同样的事情。
我们来看一个突出的例子。要创建活动,您可以子类化android.app.Activity或者子类化android.support.v7.app.AppCompatActivity。看看你在网上找到的例子和教程,在用法上似乎没有明显的区别。实际上,AppCompatActivity继承了Activity,所以哪里需要Activity,就可以用AppCompatActivity代替,它就会编译。那么,功能上有区别吗?看情况。如果你查看文档或代码,你会发现AppCompatActivity允许添加android.support.v7.app.ActionBar,而android.app.Activity不允许。相反,android.app.Activity允许添加android.app.ActionBar。而且这次android.support.v7.app.ActionBar没有继承android.app.ActionBar,所以不能把android.support.v7.app.ActionBar加到android.app.Activity上。
这基本上是说,如果你偏爱android.support.v7.app.ActionBar胜过android.app.ActionBar,你必须为一个活动使用AppCompatActivity。为什么要用android.support.v7.app.ActionBar而不是android.app.ActionBar?答案很简单:后者相当古老;从 API 级开始就有了。较新版本的android.app.ActionBar不能破坏 API 以保持与旧设备的兼容性。但是android.support.v7.app.ActionBar可以增加新的功能;它要新得多,从 API 级就存在了。
魔法现在是这样工作的:如果你使用一个说 API 等级 24 或更高的设备,你可以使用android.support.v7.app.AppCompatActivity并添加android.support.v7.app.ActionBar。你也可以使用android.app.Activity,但是你不能添加android.support.v7.app.ActionBar,而是必须使用android.app.ActionBar。因此,对于新设备,如果支持库动作栏比框架动作栏更适合您的需求,那么使用android.support.v7.app.AppCompatActivity进行活动是有意义的。
老设备呢?您仍然可以使用android.support.v7.app.AppCompatActivity,因为它是作为添加到应用中的库提供的。因此,你也可以使用现代的android.support.v7.app.ActionBar作为一个动作条,并且比老式的android.app.ActionBar拥有更多的功能。这实际上是魔术的过程。通过使用支持库,即使旧设备也可以利用后来添加的新功能!support 类的实现在内部检查设备版本,并提供合理的回退功能,以尽可能地模仿现代设备。
需要注意的是,作为开发人员,您必须同时生活在两个世界中。如果没有其他选择,您必须显式或隐式地使用框架类,并且如果您希望确保与旧设备的最大兼容性,您必须考虑使用支持库类(如果可用的话)。因此,在使用一个类之前,检查是否也有匹配的支持库类是至关重要的。你可能不喜欢 Android 中使用的这种两个世界的方法,这也意味着构建应用需要更多的思考,但这就是 Android 处理向后兼容性的方式。
如果你在你最喜欢的搜索引擎中输入 android 支持库,你将很容易找到关于支持库的详细信息。
支持库与您的应用捆绑在一起,因此它们必须在构建文件中声明为依赖项。如果你在 Android Studio 中启动一个新项目,默认情况下,它会写入模块的build.gradle文件。
dependencies {
...
implementation 'com.android.support:appcompat-v7
:26.1.0'
implementation 'com.android.support.constraint:
constraint-layout:1.0.2'
...
}
您可以看到默认情况下支持库版本 7 是可用的,因此您可以从一开始就使用它。
科特林最佳实践
开发不仅仅是解决与 IT 相关的问题或实现需求;你也想写出“好”的软件。然而,“好”在这个上下文中的确切含义有点模糊。很多方面在这里起作用:开发快,执行性能高,程序短,程序可读,程序稳定性高,等等。所有这些都有其优点,夸大其中任何一个都会阻碍其他方面。
事实上,你应该把它们都记在心里,但是我的经验告诉我要把重点放在以下几个方面:
-
让程序变得全面(或有表现力)。一个没有人理解的超级优雅的解决方案可能会让你高兴,但是请记住,以后可能其他人需要理解你的软件。
-
保持程序简单。过于复杂的解决方案容易出现不稳定性。当然,你不会某天早上醒来说,“好,今天我要写一个简单程序来解决 XYZ 需求。”编写能够可靠解决问题的简单程序是一个经验问题,需要多年的实践。但是你可以在编写简单的程序时不断尝试变得更好。一个好的起点是经常问自己,“难道不应该有一个更简单的解决方案吗?”对于软件的任何部分,在某些情况下,通过查看 API 文档和编程语言参考,您会发现更容易的解决方案与您当前拥有的解决方案一样。
-
不要重复自己。这个原则,通常被称为干编程,怎么强调都不为过。每当你发现自己在使用 Ctrl+C 和 Ctrl+V 来复制一些程序段落时,可以考虑使用一个函数或一个 lambda 表达式来提供一个完成事情的地方。
-
做预期的事情。你可以在 Kotlin 中覆盖类方法和操作符,你可以动态地添加函数到现有的类中,甚至是像
String这样的基本类。在任何情况下,通过查看它们的名字来确保这样的扩展如预期的那样工作,因为如果它们不工作,程序就很难理解。
Kotlin 在所有这些方面都有所帮助,并且经常比古老的 Java 做得更好。在接下来的部分中,我们指出了几个 Kotlin 概念,你可以用它们来使你的程序变得简短、简单和有表现力。请注意,这些概念的总和远远不是 Kotlin 的完整文档。因此,要了解更多细节,请参阅在线文档。
函数式编程
虽然函数式编程作为一种开发范式在版本 8 中进入了 Java,但 Kotlin 从一开始就支持函数式编程风格。在函数式编程中,你更喜欢不变的值而不是变量,避免状态机,并允许函数作为函数的参数。另外,lambda 演算允许传递没有名字的函数。科特林为我们提供了这一切。
在 Java 中,你可以用final修饰符来表示一个变量在第一次初始化后不会被改变。虽然大多数 Java 开发人员使用final修饰符作为常量;我很少看到开发人员在编码中使用它们。
public class Constants {
public final static int CALCULATION_PRECISION = 10;
public final static int MAX_ITERATIONS = 1000;
...
}
这是一个遗憾,因为它提高了可读性和稳定性。为了节省几个按键而省略它的诱惑实在太大了。科特林的故事是不同的;您使用val来表示数据对象在其生命周期内保持不变,如果您需要一个实变量,则使用var,如下所示:
fun getMaxFactorial():Int = 13
fun fact(n:Int):Int {
val maxFactorial = getMaxFactorial()
if(n > maxFactorial)
throw RuntimeException("Too big")
var x = 1
for( i in 1.. (n) ) {
x *= i
}
return x
}
val x = fact(12)
System.out.println("12! = ${x}")
这个简短的代码片段使用maxFactorial作为val,意思是“这是不可更改的。”然而,x是一个var,它在初始化后被改变。
我们甚至可以在阶乘计算的代码片段中避免使用var x,用一个函数构造来代替它。这是另一个函数命令:比起一个语句或一串语句,更喜欢表达式。为此,我们使用递归并编写以下代码:
fun fact(n:Int):Int = if(n>getMaxFactorial())
throw RuntimeException("Too big") else
if(n > 1) n * fact(n-1) else 1
val x = fact(10)
System.out.println("10! = ${x}")
这个小阶乘计算器只是一个简短的例子。有了收藏,故事变得更加有趣。Kotlin 标准库包括许多函数构造,您可以使用它们来编写优雅的代码。为了让您对所有的可能性有所了解,我们再次重写阶乘计算器,并使用 collections 包中的fold函数。
fun fact(n:Int) = (1..n).fold(1, { acc,i -> acc * i })
System.out.println("10! = ${fact(10)}")
为了简单起见,我删除了范围检查;如果您愿意,可以将前面的if...检查添加到{...}中的 lambda 表达式。你看我们连一个val都没有留下;不过在内部,i和acc被当作vals来处理。这甚至可以再缩短一步。因为我们使用的只是类型Int的“时间”功能,所以我们可以直接引用它并编写以下代码:
fun fact(n:Int) = (1..n).fold(1, Int::times)
System.out.println("10! = ${fact(10)}")
使用 collections 包中的其他函数构造,您可以对集合、列表和映射执行更有趣的转换。但是函数式编程也是将函数作为对象在代码中传递。在 Kotlin 中,您可以将功能分配给vals或vars,如下所示:
val factEngine: (acc:Int,i:Int) -> Int =
{ acc,i -> acc * i }
fun fact(n:Int) = (1..n).fold(1, factEngine)
System.out.println("10! = ${fact(10)}")
或者如下,这甚至更短,因为科特林在某些情况下可以推断类型:
val factEngine = { acc:Int, i:Int -> acc * i }
fun fact(n:Int) = (1..n).fold(1, factEngine)
System.out.println("10! = ${fact(10)}")
在本书中,我们尽可能使用函数构造来提高全面性和简明性。
顶级函数和数据
虽然在 Java 世界中,拥有太多全局可用的函数和数据被认为是不好的风格,例如在一些实用程序类中使用静态范围的定义,但在 Kotlin 中,这已经经历了一次复兴,看起来也更加自然。这是因为您可以在任何类之外的文件中声明函数和变量/值。不过,要使用它们,您必须导入类似于import com.example.global.*中的元素,其中包com/example.global中的任意名称的文件不包含类,而只包含fun、var和val元素。
例如,在com/example/app/util中编写一个名为common.kt的文件,并在其中添加以下内容:
package com.example.app.util
val PI_SQUARED = Math.PI * Math.PI
fun logObj(o:Any?) =
o?.let { "(" + o::class.toString() + ") " +
o.toString() } ?: "<null>"
然后添加更多的实用函数和常量。要使用它们,请编写以下内容:
import com.example.app.util.*
...
val ps = PI_SQUARED
logObj(ps)
但是,您应该谨慎使用该功能;过度使用它很容易导致结构混乱。完全避免将可变变量放在这样的范围内!您可以也应该将实用函数和全局常量放在这样的全局文件中。
类别扩展
与 Java 语言不同,Kotlin 允许动态地向类中添加方法。为此,请编写以下内容:
fun TheClass.newFun(...){ ... }
操作符也是如此,它允许你创建像"Some Text" % "magic"(这是你的想象)这样的扩展到像String这样的普通类。您应该像这样实现这个特殊的扩展:
infix operator fun String.rem(s:String){ ... }
只要确保不要无意中覆盖现有的类方法和操作符。这使得你的程序不可读,因为它做了意想不到的事情。注意,像Double.times()这样的大多数标准操作符无论如何都不能被覆盖,因为它们在内部被标记为 final。
表 10-2 描述了你可以通过operator fun TheClass.<OPER- ATOR>定义的操作符。
表 10-2
科特林算子
|标志
|
转化为
|
中缀
|
默认功能
|
| --- | --- | --- | --- |
| +a | a.unaryPlus() | | 通常什么都不做。 |
| -a | a.unaryMinus() | | 对一个数字求反。 |
| !a | a.not() | | 对布尔表达式求反。 |
| a++ | a.inc() | | 增加一个数字。 |
| a- - | a.dec() | | 减少一个数字。 |
| a + b | a.plus(b) | x | 加法。 |
| a - b | a.minus(b) | x | 减法 |
| a * b | a.times(b) | x | 乘法。 |
| a / b | a.div(b) | x | 组织。 |
| a % b | a.rem(b) | x | 除法后的余数。 |
| a . . b | a.rangeTo(b) | x | 定义一个范围。 |
| a in b | b.contains(a) | x | 密封检查。 |
| a !in b | !b.contains(a) | x | 非包容检查。 |
| a[i] | a.get(i) | | 索引访问。 |
| a[i,j,...] | a.get(i,j,...) | | 索引访问,通常不使用。 |
| a[i] = b | a.set(i,b) | | 索引设置访问。 |
| a[i,j,...] = b | a.set(i,j,...,b) | | 索引设置访问,通常不使用。 |
| a() | a.invoke() | | 祈祷。 |
| a(b) | a.invoke(b) | | 祈祷。 |
| a(b,c,...) | a.invoke(b,c,...) | | 祈祷。 |
| a += b | a.plusAssign(b) | x | 添加到a。不得返回值;而是必须修改this。 |
| a -= b | a.minusAssign(b) | x | 从a中减去。不得返回值;而是必须修改this。 |
| a *= b | a.timesAssign() | x | 乘以a。不得返回值;而是必须修改this。 |
| a /= b | a.divAssign(b) | x | 将a除以b然后赋值。不得返回值;相反,你必须修改this。 |
| a %= b | a.remAssign(b) | x | 将除法的余数除以b,然后赋值。不得返回值;而是必须修改this。 |
| a == b | a?.equals(b) ?: (b === null) | x | 检查相等性。 |
| a != b | !(a?.equals(b) ?: (b === null)) | x | 检查不平等。 |
| a > b | a.compareTo(b) > 0 | x | 对比。 |
| a < b | a.compareTo(b) < 0 | x | 对比。 |
| a >= b | a.compareTo(b) >= 0 | x | 比较。 |
| a <= b | a.compareTo(b) <= 0 | x | 对比。 |
要定义扩展,对于类型为Infix的表中的任何操作符,您需要编写以下代码:
infix operator fun TheClass.<OPERATOR>( ... ){ ... }
这里,函数参数是第二个和任何后续操作数,函数体内的this是指第一个操作数。对于非类型Infix的操作符,只需省略infix。
为自己的类定义操作符当然是个好主意。通过操作符修改标准 Java 或 Kotlin 库类也可以提高代码的可读性。
命名参数
通过使用如下命名参数:
fun person(fName:String = "", lName:String = "",
age:Int=0) {
val p = Person().apply { ... }
return p
}
你可以像这样打更有表现力的电话:
val p = person(age = 27, lName = "Smith")
使用参数名意味着您不必关心参数顺序,并且在许多情况下,您可以避免为各种参数组合重载构造函数。
范围函数
作用域函数允许你以一种不同于使用类和方法的方式来构建你的代码。例如,考虑以下代码:
val person = Person()
person.lastName = "Smith"
person.firstName = "John"
person.birthDay = "2011-01-23"
val company = Company("ACME")
虽然这是有效的代码,但重复的person.令人讨厌。况且前四行是在构造一个人,下一行和一个人无关。如果这能直观的表达出来就好了,也可以避免重复。这是 Kotlin 中的一个构造,内容如下:
val person = Person().apply {
lastName = "Smith"
firstName = "John"
birthDay = "2011-01-23"
}
company = Company("ACME")
与原始代码相比,这看起来更有表现力。上面明明说构造一个人,用它做点什么,然后再做点别的。有五个这样的结构,尽管相似,但它们在含义和用法上不同:also、apply、let、run和with。表 10-3 描述了它们。
表 10-3
范围函数
|句法
|
什么是this
|
这是什么
|
返回
|
使用
|
| --- | --- | --- | --- | --- |
| a.also {``... } | this外部语境 | a | a | 用于一些横切关注点,例如添加日志记录。 |
| a.apply {``... } | a | - | a | 用于后期构造对象成形。 |
| a.let {``... } | this外部语境 | a | 最后一个表达式 | 用于转换。 |
| a.run {``... } | a | - | 最后一个表达式 | 用一个对象做一些计算,只有副作用。为了 c 更清晰,不要使用它返回的内容。 |
| with(a) {``... } | a | - | 最后一个表达式 | 对一个对象进行分组操作。为了更清楚,不要使用它返回的内容。 |
使用作用域函数极大地提高了代码的表达能力。我在这本书里经常用到它们。
可空性
Kotlin 在语言层面上解决了可空性问题,以避免烦人的NullPointerException抛出。对于任何变量或常量,默认情况下不允许赋值null值;您必须通过在末尾添加一个?来显式声明可空性,如下所示:
var name:String? = null
然后,编译器知道示例中的name可以是null,并采取各种预防措施来避免NullPointerException s。例如,您不能编写name.toUpperCase(),但您必须使用name?.toUpperCase()来代替,它仅在name不是null时进行大写,否则返回null本身。
使用我们之前描述的作用域函数,有一种优雅的方法可以避免像if( x != null ) { ... }这样的构造。您可以改为编写以下内容:
x?.run {
...
}
这样做是一样的,但是更有表现力;凭借?.,只有当x不是null时,才会执行run{}。
elvis操作符?:也非常有用,因为它处理只有当接收变量是null时才需要计算表达式的情况,如下所示:
var x:String? = ...
...
var y:String = x ?: "default"
这和 Java 里的String y = (x != null) ? x : "default");是一样的。
数据类别
数据类是负责承载结构化数据的类。实际上,对数据类中的数据做一些事情通常是不必要的,或者至少是不重要的。
在 Kotlin 中声明数据类很容易;你所要做的就是写下以下内容:
data class Person(
val fName:String,
val lName:String,
val age:Int)
或者,如果您想对某些参数使用默认值,请使用:
data class Person(
val fName:String="",
val lName:String,
val age:Int=0)
这个简单的声明已经定义了一个构造函数、一个合适的用于比较的equals()方法、一个默认的toString()实现,以及成为析构的一部分的能力。要创建一个对象,您只需编写以下代码:
val pers = Person("John","Smith",37)
或者写一个更有表现力的版本,如下所示:
val pers = Person(fName="John", lName="Smith", age=37)
在这种情况下,如果参数声明了默认值,也可以省略参数。
这一点以及您还可以在函数中声明类和函数的事实,使得定义特定的复杂函数返回类型变得很容易,如下所示:
fun someFun() {
...
data class Person(
val fName:String,
val lName:String,
val age:Int)
fun innerFun():Person = ...
...
val p1:Person = innerFun()
val fName1 = p1.fName
...
解构
析构声明允许你多重赋值或变量。假设您有一个数据类Person,如前一节所定义。然后,您可以编写以下内容:
val p:Person = ...
val (fName,lName,age) = p
这给了你三个不同的值。数据类的顺序是由类的成员声明的顺序定义的。一般来说,任何具有component1()、component2()、...访问器可以参与析构,所以您也可以对自己的类使用析构。例如,这是默认情况下为映射条目指定的,因此您可以编写以下内容:
val m = mapOf( 1 to "John", 2 to "Greg", ... )
for( (k,v) in m) { ... }
这里,to是一个中缀操作符,它创建了一个Pair类,该类又定义了fun component1()和fun component2()。
作为析构声明的一个附加特性,可以对未使用的部分使用 _ 通配符,如下所示:
val p:Person = ...
val (fName,lName,_) = p
多行字符串文字
Java 中的多行字符串定义起来总是有点笨拙。
String s = "First line\n" +
"Second line";
在 Kotlin 中,可以如下定义多行字符串文字:
val s = """
First line
Second Line"""
您甚至可以通过添加.trimIndent()来去掉前面的缩进空格,如下所示:
val s = """
First line
Second Line""".trimIndent()
这将删除每一行开头的前导换行符和公共空格。
内部函数和类
在 Kotlin 中,函数和类也可以在其他函数中声明,这进一步有助于构建代码。
fun someFun() {
...
class InnerClass { ... }
fun innerFun() = ...
...
}
这种内部构造的范围当然仅限于声明它们的函数。
字符串插值
在 Kotlin 中,您可以将值传递给字符串,如下所示:
val i = 7
val s = "And the value of 'i' is ${i}"
这是从 Groovy 语言借用的,您可以将它用于所有类型,因为所有类型都有一个toString()成员。唯一的要求是${}的内容计算为一个表达式,因此您甚至可以编写以下代码:
val i1 = 7
val i2 = 8
val s = "The sum is: ${i1+i2}"
或者使用方法调用和 lambda 函数编写更复杂的结构:
val s = "8 + 1 is: ${ { i: Int -> i + 1 }(8) }"
限定“这个”
如果this不是您想要的,而是您想要从外部上下文中引用this,那么在 Kotlin 中,您可以使用如下的@限定符:
class A {
val b = 7
init {
val p = arrayOf(8,9).apply {
this[0] += this@A.b
}
...
}
}
授权
Kotlin 允许轻松地遵循委托模式。这里有一个例子:
interface Printer {
fun print()
}
class PrinterImpl(val x: Int) : Printer {
override fun print() { print(x) }
}
class Derived(b: Printer) : Printer by b
在这里,类Derived是类型Printer的,并将其所有方法调用委托给b对象。所以,你可以这样写:
val pi = PrinterImpl(7)
Derived(pi).print()
您可以随意覆盖方法调用,因此您可以调整委托以使用新的功能。
class Derived(val b: Printer) : Printer by b {
override fun print() {
print("Printing:")
b.print()
}
}
重命名的导入
在某些情况下,导入的类可能会使用长名称,但是您经常使用它们,所以您希望它们有较短的名称。例如,假设您经常在代码中使用SimpleDateFormat类,但不想一直写完整的类名。为了帮助我们简化这一点,您可以引入导入别名并编写以下代码:
import java.text.SimpleDateFormat as SDF
此后,您可以使用SDF代替SimpleDateFormat,如下所示:
val dateStr = SDF("yyyy-MM-dd").format(Date())
但是,不要过度使用这个特性,因为否则你的开发伙伴需要记住太多的新名字,这会使你的代码难以阅读。
JavaScript 上的 Kotlin
如果你把 Android 和 Kotlin 放在一起听,很明显你会认为 Kotlin 是 Java 的替代品,解决了 Android 运行时和 Android APIs 的问题。但还有另一种可能性,虽然不那么明显,但却开启了有趣的可能性。如果只看 Kotlin,您会发现它可以创建在 Java 虚拟机上运行的字节码,或者在 Android 的情况下在有点像 Java 的 Dalvik 虚拟机上运行。或者它可以生成在浏览器中使用的 JavaScript。问题是,我们能在 Android 中也使用它吗?答案是肯定的,在接下来的部分中,我将向您展示如何做到这一点。
创建 JavaScript 模块
我们从包含 Kotlin 文件的 JavaScript 模块开始,这些文件被编译成 JavaScript 文件。当您启动一个新模块时,没有什么像 JavaScript 模块向导一样可用,但我们可以轻松地从一个标准的智能手机应用模块开始,并转换它以满足我们的需求。
在 Android Studio 项目中,选择新➤新模块,然后选择手机和平板电脑模块。给它一个像样的名字,暂时说kotlinjsSample。生成模块后,删除以下文件夹和文件,因为我们不需要它们:
src/test
src/androidTest
src/main/java
src/main/res
src/main/AndroidManifest.xml
注意
如果你想从 Android Studio 中移除,你必须首先将视图类型从 Android 切换到 Project。
相反,添加两个文件夹。
src/main/kotlinjs
src/main/web
现在将模块的build.gradle文件的内容替换如下:
buildscript {
ext.kotlin_version = '1.2.31'
repositories {
mavenCentral()
}
dependencies {
classpath "org.jetbrains.kotlin:" +
"kotlin-gradle-plugin:$kotlin_version"
}
}
apply plugin: 'kotlin2js'
sourceSets {
main.kotlin.srcDirs += 'src/main/kotlinjs'
}
task prepareForExport(type: Jar) {
baseName = project.name + '-all'
from {
configurations.compile.collect {
it.isDirectory() ? it : zipTree(it) } +
'src/main/web'
}
with jar
}
repositories {
mavenCentral()
}
dependencies {
implementation "org.jetbrains.kotlin:" +
"kotlin-stdlib-js:$kotlin_version"
}
这个构建文件启用了 Kotlin ➤ JavaScript 编译器,并引入了一个新的导出任务。
你现在可以打开 Android Studio 窗口右侧的 Gradle 视图,在那里的others下,你会找到任务prepareForExport。要运行它,请双击它。之后,在build/libs里面你会发现一个新的文件kotlinjsSample-all.jar。这个文件代表 JavaScript 模块,供其他应用或模块使用。
在src/main/kotlinjs中创建文件Main.kt,并向其中添加内容,如下所示:
import kotlin.browser.document
fun main(args: Array<String>) {
val message = "Hello JavaScript!"
document.getElementById("cont")!!.innerHTML = message
}
最后,我们将针对一个网站,所以我们需要第一个 HTML 页面。将它作为标准的登陆页面index.html,在src/main/web中创建它,并输入以下内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kotlin-JavaScript</title>
</head>
<body>
<span id="cont"></span>
<script type="text/javascript"
src="kotlin.js"></script>
<script type="text/javascript"
src="kotlinjsSample.js"></script>
</body>
</html>
再次执行任务prepareForExport,让模块输出工件反映我们刚才所做的更改。
使用 JavaScript 模块
要使用我们在上一节中构建的 JavaScript 模块,请在应用的build.gradle文件中添加几行代码,如下所示:
task syncKotlinJs(type: Copy) {
from zipTree('../kotlinjsSample/build/libs/' +
'kotlinjsSample-all.jar')
into 'src/main/assets/kotlinjs'
}
preBuild.dependsOn(syncKotlinJs)
这将导入 JavaScript 模块的输出文件,并将其提取到应用的assets文件夹中。借助于dependsOn()声明,这个额外的构建任务会在正常构建过程中自动执行。
现在,在布局文件中放置一个WebView元素,可能如下所示:
<WebView
android:id="@+id/wv"
android:layout_width="match_parent"
android:layout_height="match_parent">
</WebView>
要用主活动的onCreate()回调中的网页填充视图,编写以下代码:
wv.webChromeClient = WebChromeClient()
wv.settings.javaScriptEnabled = true
wv.loadUrl("file:///android_asset/kotlinjs/index.html")
这将启用对WebView小部件的 JavaScript 支持,并从 JavaScript 模块加载主 HTML 页面。
作为扩展,您可能希望将网页中的 JavaScript 连接到应用中的 Kotlin 代码(而不是 JavaScript 模块)。这并不过分复杂;您只需添加以下内容:
class JsObject {
@JavascriptInterface
override fun toString(): String {
return "Hi from injectedObject"
}
}
wv.addJavascriptInterface(JsObject(), "injectedObject")
此后,您可以使用 JavaScript 模块中的injectedObject,如下所示:
val message = "Hello JavaScript! injected=" +
window["injectedObject"]
使用这些技术,你可以使用 HTML、CSS、Kotlin 转换成 JavaScript,以及一些访问器对象来处理 Android APIs,从而设计出完整的应用。
十一、构建
在这一章中,我们将讨论应用的构建过程。尽管使用终端和 Android Studio IDE 的图形界面都可以构建带有源文件的应用,但这不是 Android Studio 的介绍,也不是代码参考。对于这种类型的深入指导,请参考包括的帮助或其他书籍和在线资源。
我们在这一章要做的是看看与构建相关的概念和方法,让构建过程适应你的需要。
与构建相关的文件
在 Android Studio 中创建新项目后,您将看到以下与构建相关的文件:
-
build.gradle
这是与项目相关的顶级构建文件。它包含项目包含的所有模块共有的存储库和依赖项的声明。对于简单的应用,通常不需要编辑这个文件。
-
gradle.properties
这包含与 Gradle 构建相关的技术设置。通常不需要编辑这个文件。
-
gradlew 和 gradlew.bat
这些是包装器脚本,因此您可以使用终端而不是 Android Studio IDE 来运行构建。
-
local.properties
它保存了与您的 Android Studio 安装相关的生成的技术属性。您不应编辑此文件。
-
设置.等级
这将告诉您哪些模块是项目的一部分。如果你添加了新的模块,Android Studio 会处理这个文件。
-
app/build.gradle
这是一个与模块相关的构建文件。这是配置模块的重要依赖项和构建过程的地方。Android Studio 将为您创建第一个名为
app的模块,包括相应的构建文件,但将app作为名称只是一种约定。额外的模块会有你随意选择的不同名字,它们都有自己的构建文件。如果你愿意,甚至可以将app改名为一个更适合你需要的名字。
模块配置
项目的每个模块都包含自己的构建文件build.gradle。如果您让 Android Studio 为您创建一个新项目或模块,它也会为您创建一个初始构建文件。具有 Kotlin 支持的模块的基本构建文件如下所示(忽略¬和后面的换行符):
apply plugin: "com.android.application"
apply plugin: "kotlin-android"
apply plugin: "kotlin-android-extensions"
android {
compileSdkVersion 26
defaultConfig {
applicationId "de.pspaeth.xyz"
minSdkVersion 16
targetSdkVersion 26
versionCode 1
versionName "1.0"
testInstrumentationRunner ¬
"android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles ¬
getDefaultProguardFile( ¬
"proguard-android.txt"), ¬
"proguard-rules.pro"
}
}
}
dependencies {
implementation fileTree(dir: 'libs', ¬
include: ['*.jar'])
implementation ¬
"org.jetbrains.kotlin:kotlin-stdlib-jre7: ¬
$kotlin_version"
implementation ¬
"com.android.support:appcompat-v7:26.1.0"
implementation ¬
"com.android.support.constraint: ¬
constraint-layout:1.0.2"
testImplementation "junit:junit:4.12"
androidTestImplementation ¬
"com.android.support.test:runner:1.0.1"
androidTestImplementation ¬
"com.android.support.test.espresso: ¬
espresso-core:3.0.1"
}
注意 Gradle 中的""字符串可以包含${}占位符,而’ ’字符串则不能。除此之外,它们是可以互换的。
其内容如下:
-
apply plugin:行加载并应用 Android 和 Kotlin 开发所需的 Gradle 插件。 -
元素指定了 Android 插件的设置。
-
元素描述了模块的依赖关系。
implementation关键字意味着编译模块和运行模块都需要依赖关系。后者意味着依赖关系包含在 APK 文件中。像xyzImplementation这样的标识符指的是构建类型或者源集xyz。您可以看到,对于位于src/test的单元测试,添加了 jUnit 库,而对于src/androidTest,测试运行器和espresso都被使用。如果您更喜欢构建类型或产品风格,您可以用构建类型名称或产品风格名称替换xyz。如果您想要引用一个 variant,它是一个构建类型和产品风格的组合,您还必须在一个configurations { }元素中声明它。这里有一个例子:configurations { // flavor = "free", type = "debug" freeDebugCompile {} } -
关于
defaultConfig { }和buildTypes { },请参见以下章节。
dependencies {...}部分中的其他关键字包括:
-
实施
我们讨论过这个。它表明编译和运行应用都需要依赖关系。
-
API
这与
implementation相同,但除此之外,它还允许依赖关系泄漏到应用的客户端。 -
编译
这是
api的旧别名。不要用。 -
完全地
编译需要依赖项,但不会包含在应用中。对于像源代码预处理程序之类的纯源代码库来说,这种情况经常发生。
-
运行时仅
编译时不需要依赖项,但会包含在应用中。
模块通用配置
模块的build.gradle文件中的defaultConfig { ... }元素指定了构建的配置设置,与所选择的变量无关(见下一节)。可以在 Android Gradle DSL 参考中查找可能的设置,但常见的设置如下所示:
defaultConfig {
// Uniquely identifies the package for publishing.
applicationId 'com.example.myapp'
// The minimum API level required to run the app.
minSdkVersion 24
// The API level used to test the app.
targetSdkVersion 26
// The version number of your app.
versionCode 42
// A user-friendly version name for your app.
versionName "2.5"
}
模块构建变体
构建变体对应于构建过程生成的不同的.apk文件。构建变体的数量由以下公式给出:
Number of Build Variants =
(Number of Build Types) x (Number of Product Flavors)
在 Android Studio 中,你可以通过构建➤在菜单中选择构建变体来选择构建变体。在接下来的部分中,我们将描述什么是构建类型和产品风格。
构建类型
构建类型对应于应用开发的不同阶段。如果你开始一个项目,Android Studio 会为你设置两种构建类型:开发和发布。如果你打开模块的build.gradle文件,你可以看到里面的android { ... }(忽略¬,包括下面的换行符)。
buildTypes {
release {
minifyEnabled false
proguardFiles ¬
getDefaultProguardFile('proguard-android.txt'), ¬
'proguard-rules.pro'
}
}
尽管你在这里看不到一个debug类型,但它确实存在。它没有出现的事实仅仅意味着debug类型使用它的默认设置。如果您需要更改默认值,只需添加一个debug部分,如下所示:
buildTypes {
release {
...
}
debug {
...
}
}
您并不局限于使用预定义的构建类型之一。您可以定义其他构建类型,例如:
buildTypes {
release {
...
}
debug {
...
}
integration {
initWith debug
manifestPlaceholders = ¬
[hostName:"internal.mycompany.com"]
applicationIdSuffix ".integration"
}
}
这定义了一个名为integration的新构建类型,它通过initWith从debug继承而来,另外还添加了一个定制的 app 文件后缀,并提供了一个占位符用于清单文件。您可以在那里指定的设置相当多。如果你在你最喜欢的搜索引擎中输入 android gradle 插件 dsl 参考,你就可以找到它们。
另一个我们还没有谈到的标识符是proguardFiles标识符。一个用于过滤和/或模糊文件,这些文件将在分发应用之前包含在应用中。如果你用它来过滤,请首先权衡利弊。有了现代设备,现在省几兆也起不了多大作用。如果你想用它来混淆,注意如果反射被你的代码或者被引用的库使用,这可能会带来麻烦。而且模糊处理并不能真正防止劫机者在反编译后使用你的代码;只会让事情变得更难。所以,仔细考虑一下使用proguard的好处。如果您认为它符合您的需要,您可以在在线文档中找到有关如何使用它的详细信息。
产品风味
产品风格允许区分不同的功能集或不同的设备需求,但您可以在最适合自己的地方进行区分。
默认情况下,Android Studio 不会为一个新项目或模块准备不同的产品风格。如果您需要它们,您必须在文件build.gradle的android { ... }元素中添加一个productFlavors { ... }部分。这里有一个例子:
buildTypes {...}
flavorDimensions "monetary"
productFlavors {
free {
dimension "monetary"
applicationIdSuffix ".free"
versionNameSuffix "-free"
}
paid {
dimension "monetary"
applicationIdSuffix ".paid"
versionNameSuffix "-paid"
}
}
在这里,你可以看看 Android Gradle DSL 参考中可能的设置。这将导致以下形式的 apk:
app-free-debug.apk
app-paid-debug.apk
app-free-release.apk
app-paid-release.apk
你甚至可以扩展维度。如果在flavorDimensions行中加入更多元素,比如flavorDimensions ”monetary”, ”apilevel”,就可以加入更多的味道。
flavorDimensions "monetary", "apilevel"
productFlavors {
free {
dimension "monetary" ... }
paid {
dimension "monetary" ... }
sinceapi21 {
dimension "apilevel"
versionNameSuffix "-api21" ... }
sinceapi24 {
dimension "apilevel"
versionNameSuffix "-api24" ... }
}
这最终将为您提供以下一组 APK 文件:
app-free-api21-debug.apk
app-paid-api21-debug.apk
app-free-api21-release.apk
app-paid-api21-release.apk
app-free-api24-debug.apk
app-paid-api24-debug.apk
app-free-api24-release.apk
app-paid-api24-release.apk
为了过滤掉某些可能的变体,在构建文件中添加一个variantFilter元素,并编写以下代码:
variantFilter { variant ->
def names = variant.flavors*.name // this is an array
// To filter out variants, make a check here and then
// do a "setIgnore(true)" if you don't need a variant.
// This is just an example:
if (names.contains("sinceapi24") &&
names.contains("free")) {
setIgnore(true)
}
}
源集
如果在 Android Studio 中创建一个项目,切换到项目视图,可以看到在src文件夹里面有一个main文件夹。这对应于main源集,它是默认配置和使用的单个默认源集。见图 11-1 。
图 11-1
主源集
您可以有更多的集合,它们对应于构建类型、产品风格和构建变体。一旦您添加了更多的源集,一个构建将导致合并当前的构建变体、它所包含的构建类型、它所包含的产品风格,最后是主源集。要查看构建中将包含哪些源集,请打开窗口右侧的 Gradle 视图,并运行sourceSets任务。这将产生一个很长的列表,您可以看到如下条目:
main
Java sources: [app/src/main/java]
debug
Java sources: [app/src/debug/java]
free
Java sources: [app/src/free/java]
freeSinceapi21
Java sources: [app/src/freeSinceapi21/java]
freeSinceapi21Debug
Java sources: [app/src/freeSinceapi21Debug/java]
freeSinceapi21Release
Java sources: [app/src/freeSinceapi21Release/java]
paid
Java sources: [app/src/paid/java]
paidSinceapi21
Java sources: [app/src/paidSinceapi21/java]
release
Java sources: [app/src/release/java]
sinceapi21
Java sources: [app/src/sinceapi21/java]
这将告诉您,如果您选择一个名为freeSinceapi21Debug的构建变体,构建过程将在这些文件夹中查找类:
app/src/freeSinceapi21Debug/java
app/src/freeSinceapi21/java
app/src/free/java
app/src/sinceapi21/java
app/src/debug/java
app/src/main/java
同样,它会在相应的文件夹中查找资源、素材和AndroidManifest.xml文件。虽然 Java 或 Kotlin 类不能在这样的构建链中重复,但是清单文件以及资源和素材文件将被构建过程合并。
在文件build.gradle的dependencies { ... }部分中,您可以根据构建变体分派依赖项。只需在任何设置前添加一个骆驼大小写版本的源集。例如,如果对于freeSinceapi21变体,您想要包含一个:mylib的编译依赖项,请编写以下代码:
freeSinceapi21Compile ':mylib'
从控制台运行构建
你不必使用 Android Studio 来构建应用。虽然使用 Android Studio 引导应用项目是一个好主意,但在此之后,您可以使用终端构建应用。这就是 Gradle 包装脚本gradlew和gradlew.bat的用途。第一个是 Linux 的,第二个是 Windows 的。在下面的段落中,我们将看一下 Linux 的一些命令行命令;如果你有 Windows 开发机器,就用BAT脚本代替。
在前面的章节中,我们已经看到了每个构建的基本构建块由一个或多个在构建期间执行的任务组成。所以,我们首先想知道哪些任务实际存在。为此,要列出所有可用的任务,请输入以下内容:
./gradlew tasks
这将为您提供一个详细的列表和每个任务的一些描述。在接下来的几节中,我们将看看其中的一些任务。
要为构建类型debug或release构建应用 APK 文件,请输入以下内容之一:
./gradlew assembleDebug
./gradlew assembleRelease
这将在<PROJECT>/<MODULE>/build/outputs中创建一个 APK 文件。当然,您也可以指定您在build.gradle中定义的任何定制构建类型。
要构建调试类型 APK,然后将其安装在连接的设备或仿真器上,请输入以下内容:
./gradlew installDebug
这里,对于参数中的Debug部分,您可以使用变量的大小写名称替换任何构建变量。这将在连接的设备上安装应用。但是,它不会自动运行它;你必须手动操作!要安装和运行 app,请参见第十八章。
如果您想找出您的应用的任何模块有哪些依赖关系,请查看依赖关系树,并输入以下 or 并用app替换有问题的模块名称:
./gradlew dependencies :app:dependencies
这提供了一个相当长的清单,所以您可能希望将它通过管道传输到一个文件中,然后在编辑器中研究结果。
./gradlew dependencies :app:dependencies > deps.txt
签署
每个应用的 APK 文件都需要签名,然后才能在设备上运行。对于debug构建类型,会自动为你选择一个合适的签名配置,所以对于调试开发阶段,你不需要关心签名。
然而,一个发布的 APK 需要一个正确的签名配置。如果您使用 Android Studio 的构建➤生成签名的 APK 菜单项,Android Studio 将帮助您创建和/或使用适当的密钥。但是您也可以在模块的build.gradle文件中指定签名配置。为此,添加一个signingConfigs { ... }部分,如下所示:
android {
...
defaultConfig {...}
signingConfigs {
release {
storeFile file("myrelease.keystore")
storePassword "passwd"
keyAlias "MyReleaseKey"
keyPassword "passwd"
}
}
buildTypes {
release {
...
signingConfig signingConfigs.release
}
}
}
此外,从发布构建类型内部,引用列表中的signingConfig所示的签名配置。您需要提供的密钥库是一个标准的 Java 密钥库;请参阅 Java 的在线文档,了解如何构建一个。或者,您可以让 Android Studio 帮助您创建一个密钥库,使用在菜单中选择构建➤生成签名的 APK 时弹出的对话框。
十二、通信
通信是指通过组件、应用或设备边界发送数据。一个或多个应用的组件相互通信的标准方式是使用广播,这在第五章中讨论过。
在一个设备上进行应用间通信的另一种可能性是使用ResultReceiver对象,这些对象通过意图传递。不管它们的名字是什么,它们不仅可以在被调用的组件完成它的工作时,而且可以在它还活着的任何时候将数据发送回调用者。我们在这本书的几个地方使用了它们,但是在这一章中,我们将复习如何使用它们来实现所有的交流方式。
对于通过设备边界的通信,选项是众多的,如果我们可以使用基于云的通信平台,则更是如此。我们将讨论使用基于云的服务和通过互联网直接通信的应用间通信。
结果接收器类
通过将一个ResultReceiver对象分配给一个 intent,它可以从任何一个组件传递到另一个组件,因此您可以使用它在任何类型的组件之间发送数据,只要它们位于相同的设备上。
我们首先子类化一个ResultReceiver,它稍后将从一个被调用的组件接收消息,并编写以下代码:
class MyResultReceiver : ResultReceiver(null) {
companion object {
val INTENT_KEY = "my.result.receiver"
val DATA_KEY = "data.key"
}
override fun onReceiveResult(resultCode: Int,
resultData: Bundle?) {
super.onReceiveResult(resultCode, resultData)
val d = resultData?.get(DATA_KEY) as String
Log.e("LOG", "Received: " + d)
}
}
当然,你可以在它的onReceiveResult()函数内部写更多有意义的东西。
为了将一个MyResultReceiver的实例传递给一个被调用的组件,我们现在可以编写以下或任何其他方法来调用另一个组件:
Intent(this, CalledActivity::class.java).apply {
putExtra(MyResultReceiver.INTENT_KEY,
MyResultReceiver())
}.run{ startActivity(this) }
在被调用的组件内部,您现在可以在任何合适的位置通过类似下面的方式向调用组件发送数据:
var myReceiver:ResultReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_called)
...
myReceiver = intent.
getParcelableExtra<ResultReceiver>(
MyResultReceiver.INTENT_KEY)
}
fun go(v: View) {
val bndl = Bundle().apply {
putString(MyResultReceiver.DATA_KEY,
"Hello from called component")
}
myReceiver?.send(42, bndl) ?:
throw IllegalStateException("myReceiver is null")
}
在生产环境中,您还需要检查接收者是否还活着。为了简洁起见,我省略了这个检查。还要注意,在发送端,实际上不需要对ResultReceiver实现的引用;如果您通过应用边界进行交流,您可以只写以下内容:
...
val INTENT_KEY = "my.result.receiver"
val DATA_KEY = "data.key"
...
val myReceiver = intent.
getParcelableExtra<ResultReceiver>(
INTENT_KEY)
...
val bndl = Bundle().apply {
putString(DATA_KEY,
"Hello from called component")
}
myReceiver?.send(42, bndl)
Firebase 云消息传递
Firebase Cloud Messaging (FCM)是一个基于云的消息代理,可以用来发送和接收来自各种设备的消息,包括其他操作系统,如 Apple iOS。想法如下:你在 Firebase 控制台中注册一个应用,从此可以在连接的设备上接收和发送消息,包括在其他设备上安装你的应用。
注意
Firebase 云消息是 Google 云消息(GCM)的继任者。文档说你应该更喜欢 FCM 而不是 GCM。在本书中,我们讨论 FCM 如果你需要关于 GCM 的信息,请参考在线资源。
要从 Android Studio 内部启动 FCM,从您打开的项目进入工具➤ Firebase 的各种向导。选择云消息,然后设置 Firebase 云消息。如果您遵循那里的说明,您将最终使用两个服务。
FirebaseInstanceIdService的子类,您将在其中接收消息令牌。这个类基本上是这样的:
class MyFirebaseInstanceIdService :
FirebaseInstanceIdService() {
override
fun onTokenRefresh() {
// Get updated InstanceID token.
val refreshedToken =
FirebaseInstanceId.getInstance().token
Log.d(TAG, "Refreshed token: " +
refreshedToken!!)
}
}
它在AndroidManifest.xml中有一个对应的条目。
<service
android:name=".MyFirebaseInstanceIdService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name=
"com.google.firebase.INSTANCE_ID_EVENT"/>
</intent-filter>
</service>
当您第一次启动连接到 Firebase 的应用时,您在此收到的令牌非常重要;您需要它来使用基于 Firebase 的通信通道。该令牌不经常自动更新,因此您需要找到一种方法,以便在收到该服务中的令牌时可靠地存储该令牌。帮自己一个忙:除非您实现了存储令牌的方法,否则一定要将收到的令牌保存在日志中,因为恢复丢失的令牌会导致烦人的管理工作。
另一个服务负责接收基于 FCM 的消息。其内容如下:
class MyFirebaseMessagingService :
FirebaseMessagingService() {
override
fun onMessageReceived(remoteMessage:
RemoteMessage) {
// ...
// Check if message contains a data payload.
if (remoteMessage.data.size > 0) {
Log.d(TAG, "Message data payload: " +
remoteMessage.data)
// Implement a logic:
// For long-running tasks (10 seconds or more)
// use Firebase Job Dispatcher.
scheduleJob()
// ...or else handle message within 10 seconds
// handleNow()
}
// Message contains a notification payload?
remoteMessage.notification?.run {
Log.d(TAG, "Message Notification Body: " +
body)
}
}
private fun handleNow() {
Log.e("LOG","handleNow()")
}
private fun scheduleJob() {
Log.e("LOG","scheduleJob()")
}
}
这在AndroidManifest.xml中也有相应的条目。
<service
android:name=".MyFirebaseMessagingService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name=
"com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
要做到这一点,你需要在你的谷歌账户中激活 Firebase。有几种选择,对于高流量的信息服务,你需要购买一个计划。然而,免费版本(截至 2018 年 3 月)将为您提供足够的开发和测试能力。
如果一切设置正确,您可以使用基于 web 的 Firebase 控制台来测试向您正在运行的应用发送消息,并在日志中查看到达那里的消息。
注意
Firebase 不仅仅是消息传递;请参考您在 Firebase 控制台中找到的在线文档和信息,以及 Android Studio 给您的信息,以了解还可以做些什么。
对于发送消息,建议的解决方案是以应用服务器的形式建立一个可信环境。这超出了本书的范围,但是在线 Firebase 文档为您提供了各种提示。
与后端的通信
如前所述,使用像 Firebase 这样的基于云的供应器将你的应用连接到其他设备上的其他应用,当然有不同的优点。您有一个可靠的消息代理,它有消息备份工具、分析工具等等。
但是使用云也有它的缺点。你的数据,无论是否加密,都将离开你的房子,甚至对于企业应用来说也是如此,你不能 100%确定供应器不会在未来某个时候改变 API,迫使你改变你的应用。所以,如果你需要更多的控制,你可以放弃云,转而使用直接联网。
对于直接使用网络协议与设备或应用服务器通信,您基本上有两种选择。
-
使用 javax . net . SSL . http surlcconnection
这提供了一个低级的连接,但是包括 TLS、流功能、超时和连接池。从类名可以看出,它是标准 Java API 的一部分,所以你可以在网上找到很多关于它的信息。尽管如此,我们还是在下一节给出了描述。
-
使用 Android 附带的凌空 API
这是基本网络功能的高级包装。使用凌空大大简化了基于网络的开发,因此它通常是在 Android 中使用网络的首选。
在这两种情况下,您都需要在AndroidManifest.xml中添加适当的权限。
<uses-permission android:name=
"android.permission.INTERNET" />
<uses-permission android:name=
"android.permission.ACCESS_NETWORK_STATE" />
与 HttpsURLConnection 的通信
在使用网络通信 API 之前,我们需要确保网络操作发生在后台;现代的 Android 版本甚至不允许你在 UI 线程中执行联网。但是即使没有这种限制,也强烈建议总是在后台任务中执行联网。我们在第九章中谈到了后台操作。您想看的第一种方法是在AsyncTask中运行网络操作,但是您也可以自由选择其他方法。下面几节假设这里展示的代码片段在后台运行。
使用基于类HttpsURLConnection的通信可以归结为以下几点:
fun convertStreamToString(istr: InputStream): String {
val s = Scanner(istr).useDelimiter("\\A")
return if (s.hasNext()) s.next() else ""
}
// This is a convention for emulated devices
// addressing the host (development PC)
val HOST_IP = "10.0.2.2"
val url = "https://${HOST_IP}:6699/test/person"
var stream: InputStream? = null
var connection: HttpsURLConnection? = null
var result: String? = null
try {
connection = (URL(uri.toString()).openConnection()
as HttpsURLConnection).apply {
// ! ONLY FOR TESTING ! No SSL hostname verification
class TrustAllHostNameVerifier : HostnameVerifier {
override
fun verify(hostname: String, session: SSLSession):
Boolean = true
}
hostnameVerifier = TrustAllHostNameVerifier()
// Timeout for reading InputStream set to 3000ms
readTimeout = 3000
// Timeout for connect() set to 3000ms.
connectTimeout = 3000
// For this use case, set HTTP method to GET.
requestMethod = "GET"
// Already true by default, just telling. Needs to
// be true since this request is carrying an input
// (response) body.
doInput = true
// Open communication link
connect()
responseCode.takeIf {
it != HttpsURLConnection.HTTP_OK }?.run {
throw IOException("HTTP error code: $this")
}
// Retrieve the response body
stream = inputStream?.also {
result = it.let { convertStreamToString(it) }
}
}
} finally {
stream?.close()
connection?.disconnect()
}
Log.e("LOG", result)
这个例子试图访问一个针对您的开发 PC 的 GET URLhttps://10.0.2.2:6699/test/person,并在日志中打印结果。
注意,如果您的服务器碰巧持有 SSL 的自签名证书,您必须在初始化位置,比如在onCreate()回调中,添加以下内容:
val trustAllCerts =
arrayOf<TrustManager>(object : X509TrustManager {
override
fun getAcceptedIssuers():
Array<java.security.cert.X509Certificate>? = null
override
fun checkClientTrusted(
certs: Array<java.security.cert.X509Certificate>,
authType: String) {
}
override
fun checkServerTrusted(
certs: Array<java.security.cert.X509Certificate>,
authType: String) {
}
})
SSLContext.getInstance("SSL").apply {
init(null, trustAllCerts, java.security.SecureRandom())
}.apply {
HttpsURLConnection.setDefaultSSLSocketFactory(
socketFactory)
}
否则,前面的代码将会报错并失败。当然,与生产代码中的自签名证书相比,您更喜欢官方签名证书。
与凌空联网
凌空是一个网络库,简化了 Android 的网络。第一,凌空自己把作品发到后台;你不用管这个。凌空提供的其他好处如下:
-
调度机制
-
并行处理多个请求
-
JSON 请求和响应的处理
-
贮藏
-
诊断工具
要开始使用凌空开发,将依赖项添加到模块的build.gradle文件中,如下所示:
dependencies {
...
implementation 'com.android.volley:volley:1.1.0'
}
接下来要做的事情是设置一个RequestQueue,凌空使用它在后台处理请求。最简单的方法是在活动中写下以下内容:
val queue = Volley.newRequestQueue(this)
但是您也可以定制一个RequestQueue的创建,改为编写以下代码:
val CACHE_CAPACITY = 1024 * 1024 // 1MB
val cache = DiskBasedCache(cacheDir, CACHE_CAPACITY)
// ... or a different implementation
val network = BasicNetwork(HurlStack())
// ... or a different implementation
val requestQueue = RequestQueue(cache, network).apply {
start()
}
问题是,在哪个范围下定义请求队列最好?我们可以在活动的范围内创建并运行请求队列,这意味着每次活动重新创建自己时,都需要重新创建队列。这是一个有效的选项,但是文档建议使用应用范围来减少缓存的重新创建。推荐的方法是使用Singleton模式,其结果如下:
class RequestQueueSingleton
constructor (context: Context) {
companion object {
@Volatile
private var INSTANCE: RequestQueueSingleton? = null
fun getInstance(context: Context) =
INSTANCE ?: synchronized(this) {
INSTANCE ?: RequestQueueSingleton(context)
}
}
val requestQueue: RequestQueue by lazy {
val alwaysTrusting = object : HurlStack() {
override
fun createConnection(url: URL): HttpURLConnection {
fun getHostnameVerifier():HostnameVerifier {
return object : HostnameVerifier {
override
fun verify(hostname:String,
session:SSLSession):Boolean = true
}
}
return (super.createConnection(url) as
HttpsURLConnection).apply {
hostnameVerifier = getHostnameVerifier()
}
}
}
// Using the Application context is important.
// This is for testing:
Volley.newRequestQueue(context.applicationContext,
alwaysTrusting)
// ... for production use:
// Volley.newRequestQueue(context.applicationContext)
}
}
出于开发和测试目的,添加了一个 accept-all SSL 主机名验证器。
因此,不像前面那样写val queue = Volley.newRequestQueue(this)或val requestQueue = RequestQueue(...),而是使用下面的代码:
val queue = RequestQueueSingleton(this).requestQueue
现在,要发送一个字符串请求,您必须编写以下内容:
// This is a convention for emulated devices
// addressing the host (development PC)
val HOST_IP = "10.0.2.2"
val stringRequest =
StringRequest(Request.Method.GET,
"https://${HOST_IP}:6699/test/person",
Response.Listener<String> { response ->
val shortened =
response.substring(0,
Math.min(response.length, 500))
tv.text = "Response is: ${shortened}"
},
Response.ErrorListener { err ->
Log.e("LOG", err.toString())
tv.text = "That didn't work!"
})
queue.add(stringRequest)
这里,tv指向一个TextView UI 元素。为此,你需要一个响应https://localhost:6699/test/person的服务器。请注意,响应侦听器自动运行在 UI 线程上,因此您不必亲自处理。
要取消单个请求,在请求对象的任何地方使用cancel()。您也可以取消一组请求。像在val stringRequest = ... .apply {tag = "TheTag"}中那样给每个有问题的请求添加一个标签,然后写下queue?.cancelAll( "TheTag" )。一旦请求被取消,凌空确保响应监听器永远不会被调用。
要请求 JSON 对象或 JSON 数组,您必须替换以下内容:
val request =
JsonArrayRequest(Request.Method.GET, ...)
或者下面是我们之前使用的StringRequest:
val request =
JsonObjectRequest(Request.Method.GET, ...)
例如,对于 JSON 请求和 POST 方法,您可以编写以下代码:
val reqObj:JSONObject =
JSONObject("""{"a":7, "b":"Hello"}""")
val json1 = JsonObjectRequest(Request.Method.POST,
"https://${HOST_IP}:6699/test/json",
reqObj,
Response.Listener<JSONObject> { response ->
Log.e("LOG", "Response: ${response}")
},
Response.ErrorListener{ err ->
Log.e("LOG", "Error: ${err}")
})
凌空可以为你做更多;您可以使用其他 HTTP 方法,如PUT,也可以编写定制的请求处理并返回其他数据类型。有关更多详细信息,请参阅凌空的在线文档或其 API 文档。
设置测试服务器
这不是一个真正的 Android 主题,甚至也不是任何与 Kotlin 有关的东西,但是为了测试通信,您需要运行某种 web 服务器。为了简单起见,我通常基于 Groovy 和 Spark(不是 Apache Spark,而是来自 http://sparkjava.com /的 Java Spark)配置一个简单而强大的服务器。
要在 Eclipse 中使用它,首先要安装 Groovy 插件。然后创建一个 Maven 项目并添加依赖项,如下所示:
<dependency>
<groupId>com.sparkjava</groupId>
<artifactId>spark-core</artifactId>
<version>2.7.2</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.25</version>
<scope>test</scope>
</dependency>
之后,创建一个 Java keystore 文件,编写一个 Groovy 脚本,并启动它。
import static spark.Spark.*
def keystoreFilePath = "keystore.jks"
def keystorePassword = "passw7%d"
def truststoreFilePath = null
def truststorePassword = null
secure(keystoreFilePath, keystorePassword,
truststoreFilePath, truststorePassword)
port(6699)
get("/test/person", { req, res -> "Hello World" })
post("/test/json", { req, res ->
println(req.body())
'{ "msg":"Hello World", "val":7 }'
})
警告
为了避免 Servlet API 版本冲突,请在 Groovy 设置对话框中删除对 Servlet API 的依赖,该对话框是通过右键单击项目中的 Groovy 库并选择 Properties 打开的。
要在 Linux 下创建一个 keystore 文件,可以使用如下 Bash 脚本,并修改 Java 路径:
#!/bin/bash
export JAVA_HOME=/opt/jdk
$JAVA_HOME/bin/keytool -genkey -keyalg RSA \
-alias selfsigned -keystore keystore.jks \
-storepass passw7%d -validity 360 -keysize 2048
安卓和 NFC
NFC 用于在支持 NFC 的设备之间传输小数据包的短程无线连接。通信伙伴之间的范围被限制在几厘米。这些是典型的使用案例:
-
连接,然后读取或写入 NFC 标签
-
连接并与其他支持 NFC 的设备通信(点对点模式)
-
通过连接并与 NFC 读卡器和写卡器通信来模拟 NFC 卡
要开始开发一款支持 NFC 的应用,你需要在AndroidManifest.xml内部获得许可。
<uses-permission android:name="android.permission.NFC" />
要限制 Google Play 商店中的可见性,请将以下内容添加到同一个文件中:
<uses-feature android:name="android.hardware.nfc"
android:required="true" />
与 NFC 标签对话
一旦启用 NFC 的设备在附近发现 NFC 标签,它会尝试根据某种算法来调度标签。如果系统确定了一个 NDEF 数据并找到了一个能够处理 NDEF 的意图过滤器,那么相应的组件就会被调用。如果标签没有展示 NDEF 数据,但是通过提供关于技术和/或有效载荷的信息来识别自身,则这组数据被映射到“技术”记录,并且系统试图找到能够处理它的组件。如果两者都失败,则发现信息仅限于发现了 NFC 标签这一事实。在这种情况下,系统试图找到一个组件,该组件可以在没有 NDEF 和没有“技术”类型数据的情况下处理 NFC 标签。
基于在 NFC 标签上找到的信息,Android 还创建了一个 URI 和一个 MIME 类型,您可以将其用于意图过滤器。Android 在线开发者文档的“NFC 基础知识”页面上有更详细的描述;在你最喜欢的搜索引擎中输入 android develop nfc basics 即可找到。
要编写适当的意图过滤器,请参见第三章,此外,对于“技术”风格的发现,您需要在<activity>中添加特定的<meta-data>元素,如下所示:
<meta-data android:name="android.nfc.action.
TECH_DISCOVERED"
android:resource="@xml/nfc_tech_filter" />
这指向了在res/xml中的一个名为nfc_tech_filter.xml的文件,包含以下内容或其子集:
<resources xmlns:xliff=
"urn:oasis:names:tc:xliff:document:1.2">
<tech-list>
<tech>android.nfc.tech.IsoDep</tech>
<tech>android.nfc.tech.NfcA</tech>
<tech>android.nfc.tech.NfcB</tech>
<tech>android.nfc.tech.NfcF</tech>
<tech>android.nfc.tech.NfcV</tech>
<tech>android.nfc.tech.Ndef</tech>
<tech>android.nfc.tech.NdefFormatable</tech>
<tech>android.nfc.tech.MifareClassic</tech>
<tech>android.nfc.tech.MifareUltralight</tech>
</tech-list>
</resources>
您需要添加到意图过滤器中以有助于 NFC 分派过程的操作如下:
-
对于 NDEF 探索风格,使用以下:
<intent-filter> <action android:name= "android.nfc.action.NDEF_DISCOVERED"/> ...more filter specs... </intent-filter> -
对于技术探索风格,使用以下:
<intent-filter> <action android:name= "android.nfc.action.TECH_DISCOVERED"/> </intent-filter> <meta-data android:name= "android.nfc.action.TECH_DISCOVERED" android:resource="@xml/nfc_tech_filter" /> -
对于故障回复发现方式,请使用以下命令:
<intent-filter> <action android:name= "android.nfc.action.TAG_DISCOVERED"/> ...more filter specs... </intent-filter>
一旦与 NFC 相关的意图被分派,匹配活动就可以从意图中提取 NFC 信息。为此,请通过以下一种或多种方法获取额外的意向数据:
-
NfcAdapter.EXTRA_TAG。必需的;返回一个android.nfc.Tag对象。 -
NfcAdapter.EXTRA_NDEF_MESSAGES。可选;来自标签的 NDEF 消息。您可以通过以下方式检索它们:val rawMessages : Parcelable[] = intent.getParcelableArrayExtra( NfcAdapter.EXTRA_NDEF_MESSAGES) -
NfcAdapter.EXTRA_ID。可选;标签的低级 ID。
如果你想写 NFC 标签,在 Android 在线开发者文档的“NFC 基础知识”一页中有描述。
点对点 NFC 数据交换
Android 允许两个 Android 设备通过其光束技术进行 NFC 通信。过程如下:让支持 NFC 的设备的活动扩展CreateNdefMessageCallback并实现方法createNdefMessage( event : NfcEvent ) : NdefMessage。在这个方法中,创建并返回一个NdefMessage,如下所示:
val text = "A NFC message at " +
System.currentTimeMillis().toString()
val msg = NdefMessage( arrayOf(
NdefRecord.createMime(
"application/vnd.com.example.android.beam",
text.toByteArray() )
) )
/*
* When a device receives an NFC message with an Android
* Application Record (AAR) added, the application
* specified in the AAR is guaranteed to run. The AAR
* thus overrides the tag dispatch system.
*/
//val msg = NdefMessage( arrayOf(
// NdefRecord.createMime(
// "application/vnd.com.example.android.beam",
// text.toByteArray() ),
// NdefRecord.createApplicationRecord(
// "com.example.android.beam")
//) )
return msg
然后,NFC 数据接收应用可以在其onResume()回调中检测它是否由 NFC 发现动作发起。
override
fun onResume() {
super.onResume()
// Check to see that the Activity started due to an
// Android Beam event
if (NfcAdapter.ACTION_NDEF_DISCOVERED ==
intent.action) {
processIntent(intent)
}
}
NFC 卡仿真
让 Android 设备像带有 NFC 芯片的智能卡一样工作需要复杂的设置和编程任务。如果您考虑安全性,这尤其有意义;一些 Android 设备可能包含一个安全元件,它在硬件基础上执行与读卡器的通信。一些其他设备可能应用基于主机的卡仿真来让设备 CPU 执行通信。NFC 卡仿真的所有细节的详尽描述超出了本书的范围,但是如果您打开 Android 在线开发人员指南中的“基于主机的卡仿真”页面,您可以在网上找到相关信息。
也就是说,我们从一个基于主机的卡仿真开始描述基本的工件。该示例基于 Android 开发人员指南提供的 HCE 示例,但它被转换为 Kotlin,并归结为仅与 NFC 相关的重要方面(该示例在 Apache 许可下运行; www.apache.org/licenses/LICENSE-2.0 见)。代码内容如下:
/**
* This is a sample APDU Service which demonstrates how
* to interface with the card emulation support added
* in Android 4.4, KitKat.
*
* This sample replies to any requests sent with the
* string "Hello World". In real-world situations, you
* would need to modify this code to implement your
* desired communication protocol.
*
* This sample will be invoked for any terminals
* selecting AIDs of 0xF11111111, 0xF22222222, or
* 0xF33333333\. See src/main/res/xml/aid:list.xml for
* more details.
*
* Note: This is a low-level interface. Unlike the
* NdefMessage many developers are familiar with for
* implementing Android Beam in apps, card emulation
* only provides a byte-array based communication
* channel. It is left to developers to implement
* higher level protocol support as needed.
*/
class CardService : HostApduService() {
如果与 NFC 卡的连接丢失,则调用onDeactivated()回调,让应用知道连接断开的原因(或者是丢失的链接,或者是读卡器选择的另一个辅助设备)。
/**
* Called if the connection to the NFC card is lost.
* @param reason Either DEACTIVATION_LINK_LOSS or
* DEACTIVATION_DESELECTED
*/
override fun onDeactivated(reason: Int) {}
当收到 APDU 命令时,将调用processCommandApdu()方法。在这个方法中,可以通过返回一个字节数组来直接提供响应 APDU。一般来说,响应 APDUs 必须尽快发送,因为当调用此方法时,用户很可能将设备放在 NFC 读取器上。如果有多个服务在其元数据条目中注册了相同的辅助,则只有当用户明确选择了您的服务时,您才会被调用,无论是作为默认服务还是仅用于下一次点击。该方法运行在应用的主线程上。如果您不能立即返回一个响应 APDU,返回null并稍后使用sendResponseApdu()方法。
/**
* This method will be called when a command APDU has
* been received from a remote device.
*
* @param commandApdu The APDU that received from the
* remote device
* @param extras A bundle containing extra data. May
* be null.
* @return a byte-array containing the response APDU,
* or null if no response APDU can be sent
* at this point.
*/
override
fun processCommandApdu(commandApdu: ByteArray,
extras: Bundle): ByteArray {
Log.i(TAG, "Received APDU: " +
byteArrayToHexString(commandApdu))
// If the APDU matches the SELECT AID command for
// this service, send the loyalty card account
// number, followed by a SELECT_OK status trailer
// (0x9000).
if (Arrays.equals(SELECT_APDU, commandApdu)) {
val account = AccountStorage.getAccount(this)
val accountBytes = account!!.toByteArray()
Log.i(TAG, "Sending account number: $account")
return concatArrays(accountBytes, SELECT_OK_SW)
} else {
return UNKNOWN_CMD_SW
}
}
companion 对象包含几个常量和实用函数。
companion object {
private val TAG = "CardService"
// AID for our loyalty card service.
private val SAMPLE_LOYALTY_CARD_AID = "F222222222"
// ISO-DEP command HEADER for selecting an AID.
// Format: [Class | Instruction | Parameter 1 |
// Parameter 2]
private val SELECT_APDU_HEADER = "00A40400"
// "OK" status word sent in response to SELECT AID
// command (0x9000)
private val SELECT_OK_SW =
hexStringToByteArray("9000")
// "UNKNOWN" status word sent in response to
// invalid APDU command (0x0000)
private val UNKNOWN_CMD_SW =
hexStringToByteArray("0000")
private val SELECT_APDU =
buildSelectApdu(SAMPLE_LOYALTY_CARD_AID)
/**
* Build APDU for SELECT AID command. This command
* indicates which service a reader is
* interested in communicating with. See
* ISO 7816-4.
*
* @param aid Application ID (AID) to select
* @return APDU for SELECT AID command
*/
fun buildSelectApdu(aid: String): ByteArray {
// Format: [CLASS | INSTRUCTION |
// PARAMETER 1 | PARAMETER 2 |
// LENGTH | DATA]
return hexStringToByteArray(
SELECT_APDU_HEADER +
String.format("%02X",
aid.length / 2) +
aid)
}
/**
* Utility method to convert a byte array to a
* hexadecimal string.
*/
fun byteArrayToHexString(bytes: ByteArray):
String {
val hexArray = charArrayOf('0', '1', '2', '3',
'4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F')
val hexChars = CharArray(bytes.size * 2)
var v: Int
for (j in bytes.indices) {
v = bytes[j].toInt() and 0xFF
// Cast bytes[j] to int, treating as
// unsigned value
hexChars[j * 2] = hexArray[v.ushr(4)]
// Select hex character from upper nibble
hexChars[j * 2 + 1] = hexArray[v and 0x0F]
// Select hex character from lower nibble
}
return String(hexChars)
}
/**
* Utility method to convert a hexadecimal string
* to a byte string.
*
* Behavior with input strings containing
* non-hexadecimal characters is undefined.
*/
fun hexStringToByteArray(s: String): ByteArray {
val len = s.length
if (len % 2 == 1) {
// TODO, throw exception
}
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
// Convert each character into a integer
// (base-16), then bit-shift into place
data[i / 2] =
((Character.digit(s[i], 16) shl 4) +
Character.digit(s[i + 1], 16)).
toByte()
i += 2
}
return data
}
/**
* Utility method to concatenate two byte arrays.
*/
fun concatArrays(first: ByteArray,
vararg rest: ByteArray): ByteArray {
var totalLength = first.size
for (array in rest) {
totalLength += array.size
}
val result =
Arrays.copyOf(first, totalLength)
var offset = first.size
for (array in rest) {
System.arraycopy(array, 0,
result, offset, array.size)
offset += array.size
}
return result
}
}
}
AndroidManifest.xml中相应的服务声明如下:
<service android:name=".CardService"
android:exported="true"
android:permission=
"android.permission.BIND_NFC_SERVICE">
<!-- Intent filter indicating that we support
card emulation. -->
<intent-filter>
<action android:name=
"android.nfc.cardemulation.action.
HOST_APDU_SERVICE"/>
<category android:name=
"android.intent.category.DEFAULT"/>
</intent-filter>
<!-- Required XML configuration file, listing the
AIDs that we are emulating cards
for. This defines what protocols our card
emulation service supports. -->
<meta-data android:name=
"android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/aid:list"/>
</service>
并且我们需要在res/xml里面有一个名为aid:list.xml的文件。
<?xml version="1.0" encoding="utf-8"?>
<!-- This file defines which AIDs this application
should emulate cards for.
Vendor-specific AIDs should always start with an "F",
according to the ISO 7816 spec. We recommended
vendor-specific AIDs be at least 6 characters long,
to provide sufficient uniqueness. Note, however, that
longer AIDs may impose a burden on non-Android NFC
terminals. AIDs may not exceed 32 characters
(16 bytes).
Additionally, AIDs must always contain an even number
of characters, in hexadecimal format.
In order to avoid prompting the user to select which
service they want to use when the device is scanned,
this app must be selected as the default handler for
an AID group by the user, or the terminal must
select *all* AIDs defined in the category
simultaneously ("exact match").
-->
<host-apdu-service
xmlns:android=
"http://schemas.android.com/apk/res/android"
android:description="@string/service_name"
android:requireDeviceUnlock="false">
<!--
If category="payment" is used for any aid-groups, you
must also add an android:apduServiceBanner attribute
above, like so:
android:apduServiceBanner="@drawable/settings_banner"
apduServiceBanner should be 260x96 dp. In pixels,
that works out to...
- drawable-xxhdpi: 780x288 px
- drawable-xhdpi: 520x192 px
- drawable-hdpi: 390x144 px
- drawable-mdpi: 260x96 px
The apduServiceBanner is displayed in the "Tap & Pay"
menu in the system Settings app, and is only displayed
for apps which implement the "payment" AID category.
Since this sample is implementing a non-standard card
type (a loyalty card, specifically), we do not need
to define a banner.
Important: category="payment" should only be used for
industry-standard payment cards. If you are
implementing a closed-loop payment system (e.g.
stored value cards for a specific merchant or
transit system), use category="other". This is
because only one "payment" card may be active at
a time, whereas all "other" cards are active
simultaneously (subject to AID dispatch).
-->
<aid-group android:description=
"@string/card_title" android:category="other">
<aid-filter android:name="F222222222"/>
</aid-group>
</host-apdu-service>
服务类别还依赖于对象AccountStorage,例如,如下所示:
/**
* Utility class for persisting account numbers to disk.
*
* The default SharedPreferences instance is used as
* the backing storage. Values are cached in memory for
* performance.
*/
object AccountStorage {
private val PREF_ACCOUNT_NUMBER = "account_number"
private val DEFAULT_ACCOUNT_NUMBER = "00000000"
private val TAG = "AccountStorage"
private var sAccount: String? = null
private val sAccountLock = Any()
fun setAccount(c: Context, s: String) {
synchronized(sAccountLock) {
Log.i(TAG, "Setting account number: $s")
val prefs = PreferenceManager.
getDefaultSharedPreferences(c)
prefs.edit().
putString(PREF_ACCOUNT_NUMBER, s).
commit()
sAccount = s
}
}
fun getAccount(c: Context): String? {
synchronized(sAccountLock) {
if (sAccount == null) {
val prefs = PreferenceManager.
getDefaultSharedPreferences(c)
val account = prefs.getString(
PREF_ACCOUNT_NUMBER,
DEFAULT_ACCOUNT_NUMBER)
sAccount = account
}
return sAccount
}
}
}
安卓和蓝牙
Android 允许你添加自己的蓝牙功能。详尽地描述蓝牙的所有需求超出了本书的范围,但要了解如何做到以下几点,请参阅 Android 中蓝牙的在线文档:
-
扫描可用的本地蓝牙设备(如果您有多个设备)
-
扫描配对的远程蓝牙设备
-
扫描远程设备提供的服务
-
建立通信渠道
-
在本地和远程设备之间传输数据
-
使用配置文件
-
在您的 Android 设备上添加蓝牙服务器
我们将在这里描述 RfComm 通道的实现,以在您的智能手机和外部蓝牙服务之间传输串行数据。通过这个用例,您已经拥有了一个强大的蓝牙通信工具。例如,你可以用它来控制机器人或智能家居设备。
蓝牙 RfComm 服务器
令人惊讶的是,在网上很难找到关于设置蓝牙服务器的有价值的信息。然而,对于开发来说,有必要实现一个蓝牙服务器,这样您就可以测试 Android 应用。这样的测试服务器也可以作为您可能想到的真实场景的基础。
BlueCove 是蓝牙服务器技术的一个很好的候选者,它是一个开源项目。它的一部分是在 Apache License V2.0 下许可的,其他部分是在 GPL 下许可的,所以虽然它很容易合并到您自己的项目中,但是您需要检查对于商业项目,该许可是否适合您的需要。在下面的段落中,我将描述如何使用 BlueCove 和 Groovy 在 Linux 上设置 RfComm 蓝牙服务器。对于 Windows,您必须修改启动脚本并使用 DLL 库来代替。
从下载和安装 Groovy 开始。任何现代版本都可以。接下来,下载 BlueCove。我测试的版本是 2.1.0,但是您也可以尝试更新的版本。你需要文件bluecove-2.1.0.jar、bluecove-emu-2.1.0.jar和bluecove-gpl-2.1.0.jar。暂时将 jar 文件解压为 zip 文件,并创建一个文件夹结构,如下所示:
libbluecove.jnilib
startRfComm.sh
libbluecove.so
libbluecove_x64.so
libs/
bluecove-2.1.0.jar
bluecove-emu-2.1.0.jar
bluecove-gpl-2.1.0.jar
scripts/
rfcomm.groovy
注意
根据您使用的 Linux 发行版,您可能需要添加一个符号链接,如下所示:
cd /usr/lib/x86_64-linux-gnu/
ln -s libbluetooth.so.3 libbluetooth.so
您必须以 root 用户身份执行此操作。
注意
此外,仍然以 root 用户身份执行以下操作:
mkdir /var/run/sdb
chmod 777 /var/run/sdp
注意
此外,为了解决兼容性问题,您必须调整蓝牙服务器流程,如下所示:
cd/etc/systemd/system/bluetooth.target.wants/
里面的变化bluetooth.service像这样:
ExecStart=/usr/lib/bluetooth/bluetoothd →
ExecStart=/usr/lib/bluetooth/bluetoothd -C
然后在终端中输入以下内容
systemctl daemon-reload 和 systemctl restart bluetooth
文件startRfComm.sh是启动脚本。创建它,并在里面编写以下内容,相应地固定路径:
#!/bin/bash
export JAVA_HOME=/opt/jdk8
export GROOVY_HOME=/opt/groovy
$GROOVY_HOME/bin/groovy \
-cp libs/bluecove-2.1.0.jar:libs/bluecove-emu-2.1.0.jar
:libs/bluecove-gpl-2.1.0.jar \
-Dbluecove.debug=true \
-Djava.library.path=. \
scripts/rfcomm.groovy
服务器代码位于scripts/rfcomm.groovy中。创建它并插入以下内容:
import javax.bluetooth.*
import javax.obex.*
import javax.microedition.io.*
import groovy.transform.Canonical
// Run server as root!
// setup the server to listen for connection
// retrieve the local Bluetooth device object
LocalDevice local = LocalDevice.getLocalDevice()
local.setDiscoverable(DiscoveryAgent.GIAC)
UUID uuid = new UUID(80087355)
String url = "btspp://localhost:" + uuid.toString() +
";name=RemoteBluetooth"
println("URI: " + url)
StreamConnectionNotifier notifier = Connector.open(url)
// waiting for connection
while(true) {
println("waiting for connection...")
StreamConnection connection = notifier.acceptAndOpen()
InputStream inputStream = connection.openInputStream()
println("waiting for input")
while (true) {
int command = inputStream.read()
if(command == -1) break
println("Command: " + command)
}
}
服务器必须以 root 用户身份启动。在安装了蓝牙适配器的系统上调用sudo ./startRfComm.sh后,去掉时间戳的输出应该如下所示:
Java 1.4+ detected: 1.8.0_60; Java HotSpot(TM) 64-Bit
Server VM; Oracle Corporation
...
localDeviceID 0
...
BlueCove version 2.1.0 on bluez
URI: btspp://localhost:04c6093b00001000800000805f9b34fb;
name=RemoteBluetooth
open using BlueCove javax.microedition.io.Connector
...
connecting btspp://localhost:04
c6093b00001000800000805f9b34fb;name=RemoteBluetooth
...
created SDPSession 139982379587968
...
BlueZ major verion 4 detected
...
function sdp_extract_pdu of bluez major version 4 is called
...
waiting for connection...
Android RfComm 客户端
随着前面小节中的 RfComm 蓝牙服务器进程的运行,我们现在将为 Android 平台开发客户机。它应该执行以下操作:
-
提供一个活动来选择要连接的远程蓝牙设备
-
提供另一个活动来启动连接,并向 Bluetooth RfConn 服务器发送消息
从一个新项目开始,不要忘记添加 Kotlin 支持。将文件AndroidManifest.xml修改如下:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android=
"http://schemas.android.com/apk/res/android"
package="de.pspaeth.bluetooth">
<uses-permission android:name=
"android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name=
"android.permission.BLUETOOTH"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name=
"android.intent.action.MAIN"/>
<category android:name=
"android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name=".DeviceListActivity"
android:configChanges=
"orientation|keyboardHidden"
android:label="Select Device"
android:theme=
"@android:style/Theme.Holo.Dialog"/>
</application>
</manifest>
接下来在res/layout中创建三个布局文件。第一个是activity_main.xml,包含一个状态行和两个按钮。
<?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:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="State: " />
<TextView
android:id="@+id/state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Scan Devices"
android:onClick="scanDevices"/>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="RfComm"
android:onClick="rfComm"/>
</LinearLayout>
注意
为了简单起见,我添加了文本作为文字。在生产环境中,您当然应该使用字符串资源。
下一个布局文件device_list.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="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/title_paired_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#666"
android:paddingLeft="5dp"
android:text="Paired Devices"
android:textColor="#fff"
android:visibility="gone"
/>
<ListView
android:id="@+id/paired_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:stackFromBottom="true"
/>
<TextView
android:id="@+id/title_new_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#666"
android:paddingLeft="5dp"
android:text="Other Devices"
android:textColor="#fff"
android:visibility="gone"
/>
<ListView
android:id="@+id/new_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="2"
android:stackFromBottom="true"
/>
<Button
android:id="@+id/button_scan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Scan"
/>
</LinearLayout>
最后一个是device_name.xml,用于布置设备列表器活动中的列表项:
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android=
"http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="5dp"
android:textSize="18sp" />
DeviceListActvity类是 Android 开发人员文档中蓝牙聊天示例的 device lister 活动的改编版本。
/**
* This Activity appears as a dialog. It lists any
* paired devices and devices detected in the area after
* discovery. When a device is chosen by the user, the
* MAC address of the device is sent back to the parent
* Activity in the result Intent.
*/
class DeviceListActivity : Activity() {
companion object {
private val TAG = "DeviceListActivity"
var EXTRA_DEVICE_ADDRESS = "device_address"
}
private var mBtAdapter: BluetoothAdapter? = null
private var mNewDevicesArrayAdapter:
ArrayAdapter<String>? = null
OnItemClickListener是在 Kotlin 中实现一个单一方法接口的例子。
private val mDeviceClickListener =
AdapterView.OnItemClickListener {
av, v, arg2, arg3 ->
// Cancel discovery because it's costly and we're
// about to connect
mBtAdapter!!.cancelDiscovery()
// Get the device MAC address, which is the last
// 17 chars in the View
val info = (v as TextView).text.toString()
val address = info.substring(info.length - 17)
// Create the result Intent and include the MAC
// address
val intent = Intent()
intent.putExtra(EXTRA_DEVICE_ADDRESS, address)
// Set result and finish this Activity
setResult(Activity.RESULT_OK, intent)
finish()
}
BroadcastReceiver监听发现的设备,并在发现完成时更改标题。
/**
* Listening for discovered devices.
*/
private val mReceiver = object : BroadcastReceiver() {
override
fun onReceive(context: Context, intent: Intent) {
val action = intent.action
// When discovery finds a device
if (BluetoothDevice.ACTION_FOUND == action) {
// Get the BluetoothDevice object from
// the Intent
val device = intent.
getParcelableExtra<BluetoothDevice>(
BluetoothDevice.EXTRA_DEVICE)
// If it's already paired, skip it,
// because it's been listed already
if (device.bondState !=
BluetoothDevice.BOND_BONDED) {
mNewDevicesArrayAdapter!!.add(
device.name + "\n" +
device.address)
}
// When discovery is finished, change the
// Activity title
} else if (BluetoothAdapter.
ACTION_DISCOVERY_FINISHED == action) {
setProgressBarIndeterminateVisibility(
false)
setTitle("Select Device")
if (mNewDevicesArrayAdapter!!.count
== 0) {
val noDevices = "No device"
mNewDevicesArrayAdapter!!.add(
noDevices)
}
}
}
}
像往常一样,onCreate()回调方法设置用户界面。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Setup the window
requestWindowFeature(Window.
FEATURE_INDETERMINATE_PROGRESS)
setContentView(R.layout.activity_device_list)
// Set result CANCELED in case the user backs out
setResult(Activity.RESULT_CANCELED)
// Initialize the button to perform device
// discovery
button_scan.setOnClickListener { v ->
doDiscovery()
v.visibility = View.GONE
}
// Initialize array adapters. One for already
// paired devices and one for newly discovered
// devices
val pairedDevicesArrayAdapter =
ArrayAdapter<String>(this,
R.layout.device_name)
mNewDevicesArrayAdapter =
ArrayAdapter(this,
R.layout.device_name)
// Find and set up the ListView for paired devices
val pairedListView = paired_devices as ListView
pairedListView.adapter = pairedDevicesArrayAdapter
pairedListView.onItemClickListener =
mDeviceClickListener
// Find and set up the ListView for newly
// discovered devices
val newDevicesListView = new_devices as ListView
newDevicesListView.adapter =
mNewDevicesArrayAdapter
newDevicesListView.onItemClickListener =
mDeviceClickListener
// Register for broadcasts when a device is
// discovered
var filter =
IntentFilter(BluetoothDevice.ACTION_FOUND)
this.registerReceiver(mReceiver, filter)
// Register for broadcasts when discovery has
// finished
filter = IntentFilter(BluetoothAdapter.
ACTION_DISCOVERY_FINISHED)
this.registerReceiver(mReceiver, filter)
// Get the local Bluetooth adapter
mBtAdapter = BluetoothAdapter.getDefaultAdapter()
// Get a set of currently paired devices
val pairedDevices = mBtAdapter!!.bondedDevices
// If there are paired devices, add each one to
// the ArrayAdapter
if (pairedDevices.size > 0) {
title_paired_devices.visibility = View.VISIBLE
for (device in pairedDevices) {
pairedDevicesArrayAdapter.add(
device.name + "\n" + device.address)
}
} else {
val noDevices = "No devices"
pairedDevicesArrayAdapter.add(noDevices)
}
}
onDestroy()回调方法用于清理东西。最后,doDiscovery()方法执行实际的发现工作。
override fun onDestroy() {
super.onDestroy()
// Make sure we're not doing discovery anymore
if (mBtAdapter != null) {
mBtAdapter!!.cancelDiscovery()
}
// Unregister broadcast listeners
this.unregisterReceiver(mReceiver)
}
/**
* Start device discover with the BluetoothAdapter
*/
private fun doDiscovery() {
Log.d(TAG, "doDiscovery()")
// Indicate scanning in the title
setProgressBarIndeterminateVisibility(true)
setTitle("Scanning")
// Turn on sub-title for new devices
title_new_devices.visibility = View.VISIBLE
// If we're already discovering, stop it
if (mBtAdapter!!.isDiscovering) {
mBtAdapter!!.cancelDiscovery()
}
// Request discover from BluetoothAdapter
mBtAdapter!!.startDiscovery()
}
}
MainActivity类负责检查和获取权限,并构造一个BluetoothCommandService,我们将在后面描述。
class MainActivity : AppCompatActivity() {
companion object {
val REQUEST_ENABLE_BT = 42
val REQUEST_QUERY_DEVICES = 142
}
var mBluetoothAdapter: BluetoothAdapter? = null
var mCommandService:BluetoothCommandService? = null
活动中的onCreate()回调用于设置用户界面和注册蓝牙适配器。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val permission1 = ContextCompat.
checkSelfPermission(
this, Manifest.permission.BLUETOOTH)
val permission2 = ContextCompat.
checkSelfPermission(
this, Manifest.permission.BLUETOOTH_ADMIN)
if (permission1 !=
PackageManager.PERMISSION_GRANTED ||
permission2 !=
PackageManager.PERMISSION_GRANTED)
{
ActivityCompat.requestPermissions(this,
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN),
642)
}
mBluetoothAdapter =
BluetoothAdapter.getDefaultAdapter()
if (mBluetoothAdapter == null) {
Toast.makeText(this,
"Bluetooth is not supported",
Toast.LENGTH_LONG).show()
finish()
}
if (!mBluetoothAdapter!!.isEnabled()) {
val enableIntent = Intent(
BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(
enableIntent, REQUEST_ENABLE_BT)
}
}
scanDevices()方法用于调用系统的蓝牙设备扫描器。
/**
* Launch the DeviceListActivity to see devices and
* do scan
*/
fun scanDevices(v:View) {
val serverIntent = Intent(
this, DeviceListActivity::class.java)
startActivityForResult(serverIntent,
REQUEST_QUERY_DEVICES)
}
方法rfComm和sendMessage()处理蓝牙消息的发送。
fun rfComm(v: View) {
sendMessage("The message")
}
/**
* Sends a message.
*
* @param message A string of text to send.
*/
private fun sendMessage(message: String) {
if (mCommandService?.mState !==
BluetoothCommandService.Companion.
State.CONNECTED)
{
Toast.makeText(this, "Not connected",
Toast.LENGTH_SHORT).show()
return
}
// Check that there's actually something to send
if (message.length > 0) {
val send = message.toByteArray()
mCommandService?.write(send)
}
}
到设备的实际连接是从方法connectDevice()内部完成的。
private
fun connectDevice(data: Intent, secure: Boolean) {
val macAddress = data.extras!!
.getString(
DeviceListActivity.EXTRA_DEVICE_ADDRESS)
mBluetoothAdapter?.
getRemoteDevice(macAddress)?.run {
val device = this
mCommandService =
BluetoothCommandService(
this@MainActivity, macAddress).apply {
addStateChangeListener { statex ->
runOnUiThread {
state.text = statex.toString()
}
}
connect(device)
}
}
}
private fun fetchUuids(device: BluetoothDevice) {
device.fetchUuidsWithSdp()
}
回调方法onActivityResult()处理从系统设备选择器的返回。在这里,我们只需连接到所选的设备。
override
fun onActivityResult(requestCode: Int,
resultCode: Int, data: Intent) {
when (requestCode) {
REQUEST_QUERY_DEVICES -> {
if (resultCode == Activity.RESULT_OK) {
connectDevice(data, false)
}
}
}
}
}
Class BluetoothCommandService虽然名不副实,但却不是 Android 服务。它处理与蓝牙服务器的通信,读取内容如下:
class BluetoothCommandService(context: Context,
val macAddress:String) {
companion object {
// Unique UUID for this application
private val MY_UUID_INSECURE = UUID.fromString(
"04c6093b-0000-1000-8000-00805f9b34fb")
// Constants that indicate the current connection
// state
enum class State {
NONE, // we're doing nothing
LISTEN, // listening for incoming conns
CONNECTING, // initiating an outgoing conn
CONNECTED // connected to a remote device
}
}
private val mAdapter: BluetoothAdapter
private var createSocket: CreateSocketThread? = null
private var readWrite: SocketReadWrite? = null
var mState: State = State.NONE
private var stateChangeListeners =
mutableListOf<(State)->Unit>()
fun addStateChangeListener(l:(State)->Unit) {
stateChangeListeners.add(l)
}
init {
mAdapter = BluetoothAdapter.getDefaultAdapter()
changeState(State.NONE)
}
它的公共方法用于连接、断开和写入数据。
/**
* Initiate a connection to a remote device.
*
* @param device The BluetoothDevice to connect
*/
fun connect(device: BluetoothDevice) {
stopThreads()
// Start the thread to connect with the given
// device
createSocket = CreateSocketThread(device).apply {
start()
}
}
/**
* Stop all threads
*/
fun stop() {
stopThreads()
changeState(State.NONE)
}
/**
* Write to the ConnectedThread in an unsynchronized
* manner
*
* @param out The bytes to write
* @see ConnectedThread.write
*/
fun write(out: ByteArray) {
if (mState != State.CONNECTED) return
readWrite?.run { write(out) }
}
它的私有方法处理连接线程。
/////////////////////////////////////////////////////
/////////////////////////////////////////////////////
/**
* Start the ConnectedThread to begin managing a
* Bluetooth connection
*
* @param socket The BluetoothSocket on which the
* connection was made
* @param device The BluetoothDevice that has been
* connected
*/
private fun connected(socket: BluetoothSocket,
device: BluetoothDevice) {
stopThreads()
// Start the thread to perform transmissions
readWrite = SocketReadWrite(socket).apply {
start()
}
}
private fun stopThreads() {
createSocket?.run {
cancel()
createSocket = null
}
readWrite?.run {
cancel()
readWrite = null
}
}
/**
* Indicate that the connection attempt failed.
*/
private fun connectionFailed() {
changeState(State.NONE)
}
/**
* Indicate that the connection was lost.
*/
private fun connectionLost() {
changeState(State.NONE)
}
连接套接字处理线程本身是一个专用的Thread实现。
/**
* This thread runs while attempting to make an
* outgoing connection with a device. It runs straight
* through; the connection either succeeds or fails.
*/
private inner
class CreateSocketThread(
private val mmDevice: BluetoothDevice) :
Thread() {
private val mmSocket: BluetoothSocket?
init {
// Get a BluetoothSocket for a connection
// with the given BluetoothDevice
mmSocket = mmDevice.
createInsecureRfcommSocketToServiceRecord(
MY_UUID_INSECURE)
changeState(Companion.State.CONNECTING)
}
override fun run() {
name = "CreateSocketThread"
// Always cancel discovery because it will
// slow down a connection
mAdapter.cancelDiscovery()
// Make a connection to the BluetoothSocket
try {
// This is a blocking call and will only
// return on a successful connection or an
// exception
mmSocket!!.connect()
} catch (e: IOException) {
Log.e("LOG","Connection failed", e)
Log.e("LOG", "Maybe device does not " +
" expose service " + MY_UUID_INSECURE)
// Close the socket
mmSocket!!.close()
connectionFailed()
return
}
// Reset the thread because we're done
createSocket = null
// Start the connected thread
connected(mmSocket, mmDevice)
}
fun cancel() {
mmSocket!!.close()
}
}
为了从连接套接字读写数据,我们使用另一个线程。
/**
* This thread runs during a connection with a
* remote device. It handles all incoming and outgoing
* transmissions.
*/
private inner
class SocketReadWrite(val mmSocket: BluetoothSocket) :
Thread() {
private val mmInStream: InputStream?
private val mmOutStream: OutputStream?
init {
mmInStream = mmSocket.inputStream
mmOutStream = mmSocket.outputStream
changeState(Companion.State.CONNECTED)
}
override fun run() {
val buffer = ByteArray(1024)
var bytex: Int
// Keep listening to the InputStream while
// connected
while (mState ==
Companion.State.CONNECTED) {
try {
// Read from the InputStream
bytex = mmInStream!!.read(buffer)
} catch (e: IOException) {
connectionLost()
break
}
}
}
/**
* Write to the connected OutStream.
*
* @param buffer The bytes to write
*/
fun write(buffer: ByteArray) {
mmOutStream!!.write(buffer)
}
fun cancel() {
mmSocket.close()
}
}
最后,我们提供了一种方法来告知相关方套接字连接状态何时发生变化。这里,它还发出一个日志记录语句。对于生产代码,您可以删除它,或者以其他方式向用户提供这些信息。
private fun changeState(newState:State) {
Log.e("LOG",
"changing state: ${mState} -> ${newState}")
mState = newState
stateChangeListeners.forEach { it(newState) }
}
}
注意
伴随对象的 UUID 必须与您在服务器启动日志中看到的 UUID 相匹配。
这个类的作用如下:
-
一旦它的
connect(...)方法被调用,它就开始连接尝试。 -
如果连接成功,另一个使用 connection 对象初始化输入和输出流的线程就会启动。注意,在这个例子中没有使用输入流;此处仅供参考。
-
凭借它的
mState成员,客户端可以检查连接状态。 -
如果已连接,可以调用方法
write(...)通过连接通道发送数据。
要测试连接,请按 UI 上的 RFCOMM 按钮。然后,服务器应用应该记录以下内容:
Command: 84
Command: 104
Command: 101
Command: 32
Command: 109
Command: 101
Command: 115
Command: 115
Command: 97
Command: 103
Command: 101
这是消息“消息”的数字表示