标签 Android 下的文章

前置

针对于 PC 平台的模拟器,如果是基于 X86 架构的镜像,那么必须使用模拟器加速的技术才能运行。

PC 平台的两家 CPU 厂所需的虚拟机加速技术有所不同:

  • Intel 虚拟化技术(VT、VT-x 和 vmx)扩展
  • AMD 虚拟化(AMD-V 和 SVM)扩展

因为 x86 在 PC 平台上有良好的运行效果,但是需要相应的模拟器加速技术才能运行。据闻 Intel 应该是许久之前就已经支持了相应的虚拟化技术。而 AMD 的虚拟化技术貌似一直是老大难,没有解决。(牙膏厂还是有东西的)


直至去年(2018 年),Google & MS 团队合作后推出了兼容于 PC (x86) 平台的虚拟机加速技术,其中一个关键点是:Windows Hypervisor Platform。(喜极而泣😝)

为什么最近才提起这个旧闻呢...因为 AMD 的新 CPU 实在是太了(我入手了 3600),以及开源项目需要 Android TV 的模拟器支持(毕竟家境贫寒,勉强温饱),故而才真正面对这个问题。

开启 WHPX 技术,需要前置条件如下:

  • AMD 处理器:建议使用 AMD Ryzen 处理器。必须在计算机的 BIOS 设置中启用虚拟化或 SVM。
  • Android Studio 3.2 Beta 1 或更高版本(从 developer.android.com 下载)
  • Android 模拟器版本 27.3.8 或更高版本(使用 SDK Manager 下载)
  • Windows 10 April 2018 Update 或更高版本

开启步骤

依据官方的指南,开启的步骤看起来是相当简单的:

  1. 在 Windows 桌面上,右键点击 Windows 图标,然后选择应用程序和功能
  2. 相关设置下,点击程序和功能
  3. 点击打开或关闭 Windows 功能
  4. 选中 Windows Hypervisor Platform
  5. 点击确定
  6. 安装完成后,重启计算机。

简单吧,当然不可能这么简单。

BIOS 的 SVM

AMD 系列的主板应该不是默认开启 SVM 虚拟化技术支持。一般需要手动开启,所以需要你移步 BIOS 里开启 SVM。

打开或关闭 Windows 功能 没有 Windows Hypervisor Platform

我个人的系统版本是 Windows 10 Pro 1903(18362.388),在【打开或关闭 Windows 功能】的列表中只有 Hyper-V 选项,并没有“Windows Hypervisor Platform”。所以没有办法开启,真是令人沮丧。

在解决之前,我们先确认是否打开了 Windows Hypervisor Platform 功能,用管理员 PowerShell 运行如下指令:

Dism /online /Get-Features

如果不出意外,Windows Hypervisor Platform 这个应该是显示禁用:

功能名称 : HypervisorPlatform
状态 : 已禁用

接着运行下面的指令开启 Hyper-V 和 Windows Hypervisor Platform

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All
Get-WindowsOptionalFeature -FeatureName HypervisorPlatform -Online

键入 Y 以便部署全部内容。

部署完成后,直接重启电脑。

此时应该就可以运行虚拟机了。

在启用了 Credential Guard 或 Device Guard 的 Windows 10 主机上运行 Workstation 失败 (2146361)

Powershell 一行命令关闭核心隔离:

bcdedit /set hypervisorlaunchtype off

重启即可。


参见:

原文链接:https://github.com/Kotlin/kotlinx.coroutines/blob/master/ui/coroutines-guide-ui.md#basic-ui-coroutines

原文开源协议:https://github.com/Kotlin/kotlinx.coroutines/blob/master/LICENSE.txt

本指南假设您已经对协程这个概念有了基础的理解,如果您不了解,可以看看 Guide to kotlin.coroutines,它会给出一些协程在 UI 编程中应用的示例。

所有 UI 应用程序库都有一个普遍的问题:他们的 UI 均受限于一个主线程中,所有的 UI 更新操作都必须发生在这个特定的线程中。对于此类应用使用协程,这意味您必须有一个合适的协程调度器,将协程的执行操作限制在那个特定的 UI 线程中。

对于此,kotlin.coroutine有三个模块,他们为不同的 UI 应用程序库提供协程上下文。

kotlin-coroutines-core库里的Dispatcher.Main提供了可用的 UI 分发器实现,而ServiceLoader API 会自动发现并加载正确的实现(Android,JavaFx 或 Swing)。举个例子,如果您正在编写 JavaFx 应用程序,您可以使用Dispatcher.MainDispatcher.JavaFx扩展,他们是同一个对象。

本指南同时涵盖了所有的 UI 库,因为每个模块只包含一个长度为几页的对象定义。您可以使用其中任何一个作为示例,为您喜欢的 UI 库编写相应的上下文对象,即便它未被本文写出来。

目录

  • 设置

  • 基础 UI 协程

    • 启动 UI 协程
    • 取消 UI 协程
  • 在 UI Context 中使用 actors

    • 扩展协程
    • 最多仅有一个协程 job
    • 事件合并
  • 阻塞操作

    • UI 卡顿问题
    • 结构化并发、生命周期和协程亲子继承
    • 阻塞操作
  • 进阶提示

    • 不使用分发器在 UI 事件控制器中启动协程

设置

本指南中可运行的例子将使用 JavaFx 实现。这么做的好处是,所有的示例可以直接在任何操作需要上运行而不需要安装任何模拟器或类似的东西,并且他们是完全独立的。

JavaFx

这个基础的 JavaFx 示例程序由一个名为hello并使用Hello World!进行初始化的文本标签以及一个名为fab的桃红色的位于右下角的原型按钮组成。

ui-example-javafx

JavaFx 的 start函数将会调用setup函数,并将hellofab这两个节点的引用作为参数传递给 setup 函数。setup 函数是本指南中存放各种代码的地方:

fun setup(hello:Text, fab: Circle) {
    // 占个位
}

点击此处查看完整代码

您可以从 GitHub clone kotlinx.coroutines 项目到您本地,然后用 IDEA 打开。本指南的所有例子都在 ui/kotlinx-coroutines-javafx 模块的 test文件夹中。这样您便可以运行并观察每一个例子的运行情况以及修改项目来进行实验。

Android

跟着 Getting Started With Android and Kotlin 这份指南,在 Android Studio 中创建 Kotlin 项目。我们也推荐您使用 Kotlin Android Extensions 中的扩展特性。

在 Android Studio 2.3 中,您会得到下面的类似的应用程序界面:

ui-example-android

context_main.xml文件中,为您的TextView分配hello的资源 ID,然后使用Hello World!来初始化它。

那个桃红色的浮动按钮资源 ID 是fab

MainActivity.kt中,移除掉fab.setOnclickListener{...},接着在onCreate()方法的最后一行添加一行setup(hello, fab)来调用它。

然后在MainActivity.kt文件的尾部,给出setup()函数的实现:

fun setup(text: TextView, fab: FloatingActionButton){
    // 占位
}

在您app/build.gradledependecies{...}块中添加依赖:

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

Android 的示例存放在 ui/kotlinx-coroutines-android/example-app ,您可以clone下来运行。

基础 UI 协程

这个小节将展示协程在 UI 应用程序中的基础使用。

启动 UI 协程

kotlinx-coroutines-javafx 模块包含了Dispatchers.JavaFx 分发器,该分发器分配协程操作给 JavaFx 应用线程。

我们将之导入并用Main作为其别名,以便所有示例都可以轻松地移植到 Android 上:

import kotlinx.coroutines.javafx.JavaFx as Main

主 UI 线程的协程可以在 UI 线程上执行任何更新 UI 的操作,并且可以不阻塞主线程地挂起(suspend)操作。举个例子,我们可以编写命令式代码(imperative style)来执行动画。下面的代码将使用 launch 协程构造器,从 10 到 1 进行倒数,每隔 2 秒倒数一次并更新文本。

fun setup(hello: Text, fab: Circle) {
    GlobalScope.launch(Dispatchers.Main) { // launch coroutine in the main thread
        for (i in 10 downTo 1) { // countdown from 10 to 1 
            hello.text = "Countdown $i ..." // update text
            delay(500) // wait half a second
        }
        hello.text = "Done!"
    }
}

您可以在此获取完整的代码

那么,上面究竟发生了什么呢?因为我们在 UI 线程启动(launching)了协程,所以我们可以在该协程内自由地更新 UI 的同时还可以调用挂起函数(suspend functions),比如 delay 。当 delay 在等待时(waits),UI 并不会卡住(frozen),因为 delay 并不会阻塞 UI 线程 —— 这就是协程的挂起。

相应的 Android 应用代码是一样的。您只需要复制setup函数内的代码到 Android 项目中的对应函数中即可

取消 UI 协程

当我们想要停止一个协程的时候,我们可以持有一个由 launch函数返回的 Job 对象并利用它来取消(cancel)。

让我们通过点击桃红色的按钮来停止协程:

fun setup(hello: Text, fab: Circle) {
    val job = GlobalScope.launch(Dispatchers.Main) { // launch coroutine in the main thread
        for (i in 10 downTo 1) { // countdown from 10 to 1 
            hello.text = "Countdown $i ..." // update text
            delay(500) // wait half a second
        }
        hello.text = "Done!"
    }
    fab.onMouseClicked = EventHandler { job.cancel() } // cancel coroutine on click
}

您可以在这里获取完整代码

现在实现的效果是:当倒数正在进行时,点击圆形按钮将会停止倒数。请注意,Job.cancel 方法线程安全并且非阻塞。它只是给协程发送取消信号,而不会等待协程真正终止。

Job.cancel 该方法可以在任何地方调用,而如果在已经取消或者完成的协程上,该方法不做什么事情。

相应的 Android 代码示例如下

fab.setOnClickListener{job.cancel()}

在 UI Context 中使用 actors

在一节中,我们将会展示 UI 应用程序是如何在其 UI 上下文(Context)中使用 actors ,以确保启动的协程数量不会无限增长。

协程扩展

我们的目标是编写一个名为onClick的扩展协程构建器函数,这样每当圆形按钮被点击的时候,都会执行一个倒数动画:

fun setup(hello: Text, fab: Circle) {
    fab.onClick { // start coroutine when the circle is clicked
        for (i in 10 downTo 1) { // countdown from 10 to 1 
            hello.text = "Countdown $i ..." // update text
            delay(500) // wait half a second
        }
        hello.text = "Done!"
    }
}

我们的第一个onClick版本:在每一个鼠标事件上启动一个新的协程,并将之对应的鼠标事件传递给动作使用者:

fun Node.onClick(action: suspend (MouseEvent) -> Unit) {
    onMouseClicked = EventHandler { event ->
        GlobalScope.launch(Dispatchers.Main) { 
            action(event)
        }
    }
}

您可以在此获取完整的代码

请注意,每当圆形按钮被点击,它便会启动一个新的协程,这些新协程会竞争地更新文本。这看起来并不好,我们会在后面解决这个问题。

在 Android 中,可以为 View 类编写对应的扩展函数代码,所以上面 setup 函数中的代码可以不需要另作更改就直接使用。Android 中没有 MouseEvent,所以此处略过

fun View.onClick(action: suspend () -> Unit) {
    setOnClickListener { 
        GlobalScope.launch(Dispatchers.Main) {
            action()
        }
    }
}

最多只有一个协程 Job

我们可以在开启一个新的协程之前,取消掉一个正在运行(active)的 Job,以此来确保最多只有一个协程在执行倒计时工作。然而,通常来说这并不是一个最好的解决方法。cancel 函数仅仅发送一个取消信号去中断一个协程。取消的操作是合作性的,在现在的版本中,当协程在做一件不可取消的或类似的事件时,它是可以忽略取消信号的。

一个更好的解决方法是使用一个 actor 来确保协程不会同时进行。让我们修改onClick扩展实现:

fun Node.onClick(action: suspend (MouseEvent) -> Unit) {
    // 启动一个 actor 来接管这个节点中的所有事件
    val eventActor = GlobalScope.actor<MouseEvent>(Dispatchers.Main) {
        for (event in channel) action(event) //传递事件给 action
    }
    // install a listener to offer events to this actor
    onMouseClicked = EventHandler { event ->
        eventActor.offer(event)
    }
}

您可以在此获取完整代码

整合 actor 协程和常规事件控制(event handler)的关键点,在于 SendChannel 中有一个不中断(no wait)的 offer 函数。如果发送消息这个行为可行的话,offer 函数会立即发送一个元素给 actor ,否则该元素将会被丢弃。offer 函数会返回一个 Boolean 作为结果,不过在此该结果被我们忽略了。

试着重复点击这个版本的代码中的圆形按钮。当倒数都动画正在执行时,该点击操作会被忽略掉。这是因为 actor 正忙于动画而没有从 channel 接受消息。默认情况下,一个 actor 的消息信箱(mailbox)是由 RendezvousChannel实现的,后者的 offer操作仅在 receive 活跃时有效。

在 Android 中,View 被传递给 OnClickListener,所以我们把 view 当作信号(signal)传递给 actor 。对应的 View 类扩展如下:

fun View.onClick(action: suspend (View) -> Unit) {
    // launch one actor
    val eventActor = GlobalScope.actor<View>(Dispatchers.Main) {
        for (event in channel) action(event)
    }
    // install a listener to activate this actor
    setOnClickListener { 
        eventActor.offer(it)
    }
}

事件合并

有时候处理最新的事件比忽略掉它更合适。 actor 协程构建器接受一个可选的 capacity 参数来控制用于消息信箱(mailbox)的 channel 的实现。所有有效的选项均在 Channel() 工厂函数中有所阐述。

让我们修改代码,传递 Channel.CONFLATED 这个 capacity 参数来使用 ConflatedChannel 。只需要更改创建 actor 的那行代码即可:

fun Node.onClick(action: suspend (MouseEvent) -> Unit) {
    // launch one actor to handle all events on this node
    val eventActor = GlobalScope.actor<MouseEvent>(Dispatchers.Main, capacity = Channel.CONFLATED) { // <--- Changed here
        for (event in channel) action(event) // pass event to action
    }
    // install a listener to offer events to this actor
    onMouseClicked = EventHandler { event ->
        eventActor.offer(event)
    }
}

您可以在此获取完整的 JavaFx 代码。在 Android 上,您需要修改之前示例中的 val eventActor = ... 这一行。

现在,如果动画正在进行时圆形按钮被点击了,动画将会在结束之后重新启动。仅会重启一次。当动画进行时,重复的点击操作将会被合并,而仅有最新的事件会被处理。

这对于那些需要接收高频率事件流,并基于最新事件更新 UI 的 UI 应用程序而言,也是一种合乎需求的行为( a desired behaviour )。使用 ConflatedChannel 的协程可以避免由事件缓冲(buffering of events)带来的延迟。

您可以试验不同的 capacity 参数来看看上面代码的效果和行为。设置 capacity = Channel.UNLIMITED 将创建一个 LinkedListChannel 实现的信箱,这会缓冲所有事件。在这种情况下,动画的执行次数和圆形按钮点击次数保持一致。

阻塞操作

这一小节将解释如何在 UI 协程中完成线程阻塞操作(thread-blocking operations)。

UI 卡顿问题

The problem of UI freezes

如果所有 API 接口函数均以挂起函数(suspending functions)来实现那是最好不过的事情了,这样那些函数将永远不会阻塞调用它们的线程。然而,事实往往并非如此。比如,有时候您必须做一些消耗 CPU 的计算操作,或者只是需要调用第三方的 API 来访问网络,这些行为都会阻塞调用函数的线程。您无法在 UI 线程或是 UI 线程启动的协程直接做上述操作,因为那会直接阻塞 UI 线程从而导致 UI 界面卡顿。

下面的例子将会展示这个问题。我们将使用 onClick 扩展和上一节中的 UI 限制性事件合并 actor 来处理 UI 线程的最后一次点击。

举个例子,我们将进行 斐波那契数列 的简单演算:

fun fib(x: Int): Int = 
    if (x <= 1) x else fib(x - 1) + fib(x - 2)

每当圆形按钮被点击,我们都会进行更大的斐波那契数的计算。为了让 UI 卡顿变得明显可见,将会有一个持续执行的快速的计数器动画,并在 UI 分发器(dispatcher)更新文本:

fun setup(hello: Text, fab: Circle) {
    var result = "none" // the last result
    // counting animation 
    GlobalScope.launch(Dispatchers.Main) {
        var counter = 0
        while (true) {
            hello.text = "${++counter}: $result"
            delay(100) // update the text every 100ms
        }
    }
    // compute the next fibonacci number of each click
    var x = 1
    fab.onClick {
        result = "fib($x) = ${fib(x)}"
        x++
    }
}

您可以在这里获取完整的 JavaFx 代码。您只需要复制 fib 函数及 setup 函数体内代码到您的 Android 项目即可

试着点击例子中的圆形按钮。大概第在 30~40 次点击后,我们的计算将会变得缓慢,接着您会立刻看到 UI 卡顿,因为倒数动画在 UI 卡顿的时候停止了。

结构化并发、生命周期和协程亲子继承

一个典型的 UI 应用程序拥有许多生命周期元素。Windows、UI 控制、activities,views,fragments 以及其他可视化元素将会被创建和销毁。一个长时间运行的协程,在后台执行着诸如 IO 或计算操作,如果它持有 UI 元素的引用,那么可能导致 UI 元素生命周期过长,继而阻止那些已经销毁并且不再显示的 UI 树被 GC 收集和回收。

一个自然的解决方法是将一个 Job 对象关联到 UI 对象,后者拥有生命周期并在其上下文(Context)中创建协程。但是传递已关联的 Job 对象给所有线程构造器容易出错,而且这个操作容易被遗忘。故此,CoroutineScope 接口可以被 UI 所有者所实现,然后每一个在 CoroutineScope 上定义为扩展的协程构造器都将继承 UI 的 Job,而无需显式声明。为了简单起见,可以使用 MainScope() 工厂方法。它会自动提供 Dispatchers.Main 及其父级 job 。

举个例子,在 Android 应用程序中,一个 Activitycreated 中被初始化,而当其不再被需要或者其内存必须被释放时,该对象被销毁destroyed)。一个自然的解决方法是为一个 Activity 实例对象附加一个 Job 实例对象:

abstract class ScopedAppActivity: AppCompatActivity(), CoroutineScope by MainScope() {
    override fun onDestroy() {
        super.onDestroy()
        cancel() // CoroutineScope.cancel
    } 
}

现在,继承 ScopedAppActivity 来让一个 activity 和一个 job 关联起来:

class MainActivity : ScopedAppActivity() {

    fun asyncShowData() = launch { // Is invoked in UI context with Activity's job as a parent
        // actual implementation
    }
    
    suspend fun showIOData() {
        val deferred = async(Dispatchers.IO) {
            // impl      
        }
        withContext(Dispatchers.Main) {
          val data = deferred.await()
          // Show data in UI
        }
    }
}

每个从MainActivity中启动(launched)的协程都将拥有它的 job 作为其父亲,当 activity 被销毁时,协程将会被立刻取消(canceled)。

可以使用多种方法,来将 activtiy 的 scope 传递给它的 Views 及 Presenters:

class ActivityWithPresenters: ScopedAppActivity() {
    fun init() {
        val presenter = Presenter()
        val presenter2 = ScopedPresenter(this)
    }
}

class Presenter {
    suspend fun loadData() = coroutineScope {
        // Nested scope of outer activity
    }
    
    suspend fun loadData(uiScope: CoroutineScope) = uiScope.launch {
      // Invoked in the uiScope
    }
}

class ScopedPresenter(scope: CoroutineScope): CoroutineScope by scope {
    fun loadData() = launch { // Extension on ActivityWithPresenters's scope
    }
}

suspend fun CoroutineScope.launchInIO() = launch(Dispatchers.IO) {
   // Launched in the scope of the caller, but with IO dispatcher
}

jobs 间的亲子关系形成了层级结构。一个代表视图在后台执行工作的协程,可以进一步创建子协程。当父级 job 被取消的时候,整个协程树都将被取消。协程指南中的“子协程”用一个例子阐述了这些用法。

阻塞操作

使用协程可以非常简单地解决 UI 线程上的阻塞操作。我们将把我们的“阻塞” fib 函数转换为挂起函数,然后通过使用 withContext 函数来将把后台运算部分的线程的执行上下文(execution context)转换为 Dispatchers.DefaultDispatchers.Default 由一个后台线程池( background pool)实现。请注意,fib函数现在标有 suspend 修饰符。这表示无论它怎么被调用都会不会阻塞协程,而是在后台线程执行计算时,挂起它的操作。

suspend fun fib(x: Int): Int = withContext(Dispatchers.Default) {
    if (x <= 1) x else fib(x - 1) + fib(x - 2)
}

您可以在这里 获取完整代码。

您可以运行上述代码然后确认在计算较大的斐波那契数时 UI 不会被卡住。然而,这段 fib计算代码速度稍慢,因为每一次都是通过 withContext 来递归调用的。这在练习中并不是什么大问题,因为 withContext 能够机智地检查该协程是否已经在所需的上下文中,然后避免过度分发(dispatching)协程到不同的线程。尽管如此,这仍是一种开销。它在原生代码(primitive code)上是可见的,并且它除了调用 withContext 之间提供整数以外,不做其他工作。对于一些实际性的代码, withContext 的开销不会很明显。

尽管如此,这个特定实现的可在后台线程工作的 fib 函数也可以变得和没有使用挂起函数时一样快,只需要重命名原来的 fib 函数为 fibBlocking 然后定义一个用 withContext 包装在 fibBlocking 顶部的 fib 函数即可:

suspend fun fib(x: Int): Int = withContext(Dispatchers.Default) {
    fibBlocking(x)
}

fun fibBlocking(x: Int): Int = 
    if (x <= 1) x else fibBlocking(x - 1) + fibBlocking(x - 2)

您可以在这里 获取完整代码。

您现在可以享受全速(full-speed)的原生斐波那契数计算而不会阻塞 UI 线程了。我们仅仅需要 withContext(Dispatchers.Default) 而已。

请记住,因为在我们代码中 fib 函数是被单个 actor 所调用的,故而在任何时间都最多只有一个并行运算。所以这份代码在资源利用上有着天然的限制性。它最多只能占用一个 CPU 核心。

进阶提示

这个小结覆盖了多种进阶提示。

不使用分发器在 UI 事件控制器中启动协程

让我们用下列 setup 函数中的代码来形象展示协程从 UI 中启动的执行步骤:

fun setup(hello: Text, fab: Circle) {
    fab.onMouseClicked = EventHandler {
        println("Before launch")
        GlobalScope.launch(Dispatchers.Main) {
            println("Inside coroutine")
            delay(100)
            println("After delay")
        } 
        println("After launch")
    }
}

您可以在这里获取完整的 JavaFx 代码。

当我们运行代码并点击桃红色的圆形按钮,控制台将会打印出如下信息:

Before launch
After launch
Inside coroutine
After delay

正如您所见,launch 后的操作被立刻执行了,而发布到 UI 线程的协程则在其之后才执行。所有在 kotlinx.coroutines 的分发器都是如此实现的。为什么要这样呢?

基本上,这是在 “JavaScript 风格”异步方法(异步操作总是被延迟给事件分发线程执行)和 “C# 风格”异步方法(异步操作在调用者线程遇到第一个挂起函数时被执行)之间的选择。尽管 C# 风格看起来更有效率,但是它最终建议诸如“如果您需要时请使用 yield ...”的信息。这样是容易出错的。JavaScript 风格的方法更加一致,它也不要求编程人员去思考什么时候该或不该使用 yield

然而,当协程从事件控制器(event handler)启动,并且没有其周围没有其它的代码,这中特殊情况下,此种额外的分派确实会带来额外的开销,并且没有其他的附加价值。在这样的情况下, launchasyncactor 三种协程构造器均可以传递一个可选的 CoroutineStart 参数来优化性能。传递 CoroutineStart.UNDISPATCHED 参数将会实现:遇到首个挂在函数便立刻执行协程的效果。正如下面代码所示:

fun setup(hello: Text, fab: Circle) {
    fab.onMouseClicked = EventHandler {
        println("Before launch")
        GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) { // <--- Notice this change
            println("Inside coroutine")
            delay(100)                            // <--- And this is where coroutine suspends      
            println("After delay")
        }
        println("After launch")
    }
}

您可以在此获取到完整的 JavaFx 代码。

当点击时,下面的信息将会被打印出来,可以确认协程中的代码被立刻执行:

Before launch
Inside coroutine
After launch
After delay

1. RxJava 中的异步控制

在以前也遇到了类似的场景,那时候还是再使用AsyncTask的时候。
我们知道AsyncTask中有doInBackground()方法是一个子线程的异步方法。我们一般在里面执行耗时操作。
但是我们会在doInBackground()中执行一个耗时的异步操作吗?看看下面的例子

...
protected boolean doInBackground(String... urls) {
        loadImageFromNetwork(urls[0], targetObej);
        return true;
}
...    

这里的示例中,我们调用loadImageFromNetwork()方法,将第一个参数中的图片下载下来,然后填充到targetObjet这个对象中去。
但是这里调用的实际上是一个异步操作,程序调用了loadImageFromNetwork()就顺序执行了,返回了一个true值。
这样的话再加载情况未知的情况下,程序逻辑已经继续执行下去了。

如果只是使用RxJava来控制业务逻辑的话,那么异步线程里再开一个子线程的问题也会遇到同样问题呀:

/**
 * @author rosu on 2018/10/27
 * 这个类用于展示 Rxjava 中异步操作中继续调用异步方法的情况
 */
public class FlatMapWithChildProcess {
    public static void main(String[] args) {
        Observable
                .create((ObservableOnSubscribe<Integer>) emitter -> {
                    System.out.println("Create1 ===>>> 创建并发射事件");
                    System.out.println("当前线程====>>> 1" + Thread.currentThread().getName());
                    emitter.onNext(1);
                })
                .flatMap((Function<Integer, ObservableSource<Integer>>) integer -> {
                    System.out.println("当前线程====>>> 2" + Thread.currentThread().getName());
                    hardWork();
                    System.out.println("顺序执行了");
                    return Observable.just(integer);
                })
                .subscribe(new Observer<Integer>() {
                    @Override
                    public void onSubscribe(Disposable d) {
                        System.out.println("onSubscribe =======>>> 开始订阅事件");
                    }

                    @Override
                    public void onNext(Integer integer) {
                        System.out.println("onNext ======>>> " + integer);

                    }

                    @Override
                    public void onError(Throwable e) {

                    }

                    @Override
                    public void onComplete() {
                        System.out.println("onComplete ========>>> ");
                    }
                });
    }

    private static void hardWork(){
        new Thread(() -> {
            try {
                System.out.println("当前线程====>>> 3" + Thread.currentThread().getName());
                Thread.sleep(10000);
                System.out.println("睡眠完成的子线程");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

输出为:

onSubscribe =======>>> 开始订阅事件
Create1 ===>>> 创建并发射事件
当前线程====>>> 1main
当前线程====>>> 2main
当前线程====>>> 3Thread-0
顺序执行了
onNext ======>>> 101
睡眠完成的子线程

虽然例子看起来有点长,但内容不多。我们只是尝试在flatMap()调用了hardWork()方法,该方法中中起了一个耗时子线程。
看到输出的结果也在我们的意料之中,原有的工作逻辑在调用了hardWork()之后就继续执行了,因为他无法得知那个方法是个异步的方法,也无法获得该方法的执行状态。

例子比较简单,在实际的工作中我们可能会遇到一些情况,考虑这样一个例子:

  1. 利用 RxJava 循环发射一些事件,常见是用fromArray()intervalRange()这样的方法

    • 此处我们发射一个url链接数组的元素
  2. 我们利用了 RxJava 本身的特性来控制业务逻辑,包括对每个事件的处理

    • 此处,我们可能是对url做一些拼接或者判断有效性的工作
  3. 之后我们需要利用发射的事件做耗时操作

    • 此处,我们是利用url来下载文件,假设调用了download(url:Int)方法
  4. 最后在onComplete()方法中完成视图操作

这看起来是非常正常的业务逻辑,唯一值得注意的地方应该是耗时操作这个地方。我们先来看看例子的简易代码示例:

public class FlatMapWithChildProcess {
    public static void main(String[] args) {
        Observable
                .fromArray(1, 2, 3, 4, 5, 6)
                .flatMap((Function<Integer, ObservableSource<Integer>>) integer -> {
                    download(integer);
                    return Observable.just(integer);
                })
                .subscribe(new Observer<Integer>() {
                    @Override
                    public void onSubscribe(Disposable d) {
                        System.out.println("onSubscribe =======>>> 开始订阅事件");
                    }

                    @Override
                    public void onNext(Integer integer) {
                        System.out.println("onNext ======>>> " + integer);

                    }

                    @Override
                    public void onError(Throwable e) {

                    }

                    @Override
                    public void onComplete() {
                        System.out.println("onComplete ========>>> RxJava 事件完成了");
                        UpdateUI();
                    }
                });
    }

    private static void download(int pos){
        new Thread(() -> {
            try {
                System.out.println("接到工作 ===>>> " + pos);
                Thread.sleep(10000);
                System.out.println("完成工作 ===>>> " + pos + "\n 时间:" + System.currentTimeMillis());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

看一下输出:

onSubscribe =======>>> 开始订阅事件
接到工作 ===>>> 1
onNext ======>>> 1
接到工作 ===>>> 2
onNext ======>>> 2
接到工作 ===>>> 3
onNext ======>>> 3
接到工作 ===>>> 4
onNext ======>>> 4
onNext ======>>> 5
接到工作 ===>>> 5
onNext ======>>> 6
接到工作 ===>>> 6
onComplete ========>>> RxJava 事件完成了
完成工作 ===>>> 1
 时间:1543916254101
完成工作 ===>>> 4
 时间:1543916254103
完成工作 ===>>> 6
 时间:1543916254103
完成工作 ===>>> 3
 时间:1543916254103
完成工作 ===>>> 2
 时间:1543916254103
完成工作 ===>>> 5
 时间:1543916254103

这里可以看到我们耗时操作还没做完,RxJava就已经回调了onComplete()了。所以这显然是不行的。

2. Flowable 能拯救这段代码吗?

众所周知...RxJava2 带来了Flowable这个新的观察者。又一个众所周知,Flowable是一个带有背压控制的观察者。
那么背压控制,能解决这个问题吗?

2.1 Flowable 的背压误区?FlatMap 初探

于是我随手写了这段代码:

public class FlowableWithBackPressure {
    public static void main(String[] args) {
        Flowable
                .fromArray(1, 2, 3, 4, 5)
                .flatMap((Function<Integer, Publisher<Integer>>) integer -> {
                    download(integer);
                    return Flowable.just(integer);
                })
                .map(integer -> integer + 20)
                .subscribe(new FlowableSubscriber<Integer>() {
                    @Override
                    public void onSubscribe(Subscription s) {
                        
                    }

                    @Override
                    public void onNext(Integer integer) {
                        System.out.println("RxJava =======>>> onNext");
                    }

                    @Override
                    public void onError(Throwable t) {

                    }

                    @Override
                    public void onComplete() {
                        System.out.println("RxJava =======>>> 时间完成了");
                    }
                });


    }

    private static void download(int pos){
        new Thread(() -> {
            try {
                System.out.println("接到工作 ===>>> " + pos);
                Thread.sleep(500);
                System.out.println("完成工作 ===>>> " + pos + "\n 时间:" + System.currentTimeMillis());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

看一下输出:

接到工作 ===>>> 1
接到工作 ===>>> 2
接到工作 ===>>> 3
接到工作 ===>>> 4
接到工作 ===>>> 5
完成工作 ===>>> 1
 时间:1543918837921
完成工作 ===>>> 3
 时间:1543918837924
完成工作 ===>>> 4
 时间:1543918837924
完成工作 ===>>> 2
 时间:1543918837924
完成工作 ===>>> 5
 时间:1543918837926

咦?貌似有什么地方出了问题啊。按照我的平时认知,没有调用Subscription.request(),就不会发射事件才对啊。
为啥这里还是调用了呢?当然熟悉的人一下子就看出来了。
问题出在与flatmap()方法。

实际上对于flatMap()之类的方法,是将原来的事件流转换为新的类型的事件流。问题就在这里了。
转换的步骤,根据文档的说明是,flatMap()会将每个事件重新包装,最后再将所有事件合并发射。这样的话,实际上就是又构造了一个新的事件发射器,也就是一个新的『上游』

事件的『上游』和『下游』

我之前粗浅的认知里,以为第一个发射的源头是上游,其他都是下游。实际上『上下游』是一个相对的概念,比如这里的flatMap(),他重新包装了事件并重新发射了,他就是一个新的『上游』。这样的话,肯定所有事件不需要request()就可以直接发射到这个flatMap()方法里面了。

2.2 Flowable 和 背压控制

我们了解了『上下游』概念之后,其实横在我们面前的是,如何正确地动态控制事件发射呢?
众所周知,Flowable 带来的背压控制的概念。我们前面也提到了通过Subscription.request()来控制上游发射。
但是类似 2.1 中举的例子,在上游你是无法控制的。而且我们又要利用RxJava来控制业务逻辑,也就是对每个链接进行处理。
这样的话,实际上我们只能在下游动态拉取才行。动态拉取就是Subscription.request()啊?
那该怎么做呢?
其实很简单,我们把耗时操作放在onNext()里就行了:

public class FlowableWithBackPressure {
    private static Subscription mSubscription;

    public static void main(String[] args) {
        Flowable
                .fromArray(1, 2, 3, 4, 5)
                .map(integer -> integer + 20)
                .subscribe(new FlowableSubscriber<Integer>() {
                    @Override
                    public void onSubscribe(Subscription s) {
                        mSubscription = s;
                        mSubscription.request(1);
                    }

                    @Override
                    public void onNext(Integer integer) {
                        System.out.println("onNext() =======>>> 调用 download() 方法");
                        download(integer);
                    }

                    @Override
                    public void onError(Throwable t) {

                    }

                    @Override
                    public void onComplete() {
                        System.out.println("RxJava =======>>> 时间完成了");
                    }
                });


    }

    private static void download(int pos){
        new Thread(() -> {
            try {
                System.out.println("接到工作 ===>>> " + pos);
                Thread.sleep(3000);
                System.out.println("完成工作 ===>>> " + pos + "\n 时间:" + System.currentTimeMillis() + "\n 准备拉取");
                mSubscription.request(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

可以看到,任务执行到onNext()方法,然后我们在里面调用了一个异步的耗时操作,等到耗时操作完成之后,采取拉取下一个事件发射。

这是输出结果:

onNext() =======>>> 调用 download() 方法
接到工作 ===>>> 21
完成工作 ===>>> 21
 时间:1543983837259
 准备拉取
onNext() =======>>> 调用 download() 方法
接到工作 ===>>> 22
完成工作 ===>>> 22
 时间:1543983840264
 准备拉取
onNext() =======>>> 调用 download() 方法
接到工作 ===>>> 23
完成工作 ===>>> 23
 时间:1543983843269
 准备拉取
onNext() =======>>> 调用 download() 方法
接到工作 ===>>> 24
完成工作 ===>>> 24
 时间:1543983846275
 准备拉取
onNext() =======>>> 调用 download() 方法
RxJava =======>>> 时间完成了
接到工作 ===>>> 25
完成工作 ===>>> 25
 时间:1543983849278
 准备拉取

这样我们就完成了原来的目标,既用了RxJava控制业务逻辑,又在其中做了耗时操作并动态拉取事件,也就是背压控制。
如果耗时操作并不是业务最下游,那么我们可以使用doOnNext()方法来达到同样的效果:

public class FlowableWithBackPressure {
    private static Subscription mSubscription;

    public static void main(String[] args) {
        Flowable
                .fromArray(1, 2, 3, 4, 5)
                .map(integer -> integer + 20)
                .doOnNext(new Consumer<Integer>() {
                    @Override
                    public void accept(Integer integer) throws Exception {
                        System.out.println("doOnNext() =======>>> 调用 download() 方法");
                        download(integer);
                    }
                })
                .subscribe(new FlowableSubscriber<Integer>() {
                    @Override
                    public void onSubscribe(Subscription s) {
                        mSubscription = s;
                        mSubscription.request(1);
                    }

                    @Override
                    public void onNext(Integer integer) {
                        System.out.println("onNext()2 =======>>>");
                    }

                    @Override
                    public void onError(Throwable t) {

                    }

                    @Override
                    public void onComplete() {
                        System.out.println("RxJava =======>>> 时间完成了");
                    }
                });


    }

    private static void download(int pos){
        new Thread(() -> {
            try {
                System.out.println("接到工作 ===>>> " + pos);
                Thread.sleep(3000);
                System.out.println("完成工作 ===>>> " + pos + "\n 时间:" + System.currentTimeMillis() + "\n 准备拉取");
                mSubscription.request(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

这是输出:

doOnNext() =======>>> 调用 download() 方法
接到工作 ===>>> 21
onNext()2 =======>>>
完成工作 ===>>> 21
 时间:1543984443387
 准备拉取
doOnNext() =======>>> 调用 download() 方法
onNext()2 =======>>>
接到工作 ===>>> 22
完成工作 ===>>> 22
 时间:1543984446390
 准备拉取
doOnNext() =======>>> 调用 download() 方法
onNext()2 =======>>>
接到工作 ===>>> 23
完成工作 ===>>> 23
 时间:1543984449396
 准备拉取
doOnNext() =======>>> 调用 download() 方法
onNext()2 =======>>>
接到工作 ===>>> 24
完成工作 ===>>> 24
 时间:1543984452400
 准备拉取
doOnNext() =======>>> 调用 download() 方法
onNext()2 =======>>>
RxJava =======>>> 事件完成了
接到工作 ===>>> 25
完成工作 ===>>> 25
 时间:1543984455406
 准备拉取

doOnNext()可以注册一个回调,每当ObservableonNext()调用之前就会调用本方法。

3. 还有一点小问题

我们把耗时操作放在了onNext()中调用,也就是调用了download()方法。
但是这样的还是有两个明显的问题:

  1. download()方法和 RxJava 流耦合了,因为用到了Subscription.request()
  2. 最后的一个事件依旧是还未download()完就调用了onComplete(),这是肯定的。因为最后一个事件之后,并不在需要request()了。所以事件流就结束了

    • 看一下上面的输出,先输出了RxJava =======>>> 事件完成了

看到这两个问题,其实我们就该思考这种做法一开始就存在了问题。本身RxJava就良好地支持了异步回调控制的功能,如果非要在异步中再加上异步,造成的问题就是子线程状态难以控制。

其实我们一开始就可以把download()方法写成同步方法,这样的话,利用RxJava本身对线程的控制能力,我们一样可以轻松地实现类似的业务需求。

Android 的存储结构

下面的『内外』,是相对应用而言的。应用内部沙盒称为内部存储,其外部称为外部存储。

内部存储

位置

Android 内部存储在/data/data/目录下,根据应用的包名划分出来。
每个应用都有如下几个子文件夹:

  • data/data/包名/shared_prefs:存放该APP内的SP信息
  • data/data/包名/databases:存放该APP的数据库信息
  • data/data/包名/files:将APP的文件信息存放在files文件夹
  • data/data/包名/cache:存放的是APP的缓存信息

读取方法

内部存储不需要申请读取权限,可以任君使用!
读写文件分别使用:

  • openFileOutput()

    • write() 写入
    • close() 关闭
  • openFileInput

    • read() 读取
    • close()关闭

也可以直接使用:

  • getCacheDir()来获取缓存目录
  • getFilesDir()来获取文件目录

外部存储

外置存储就必须申请权限,而且这里也有一些需要注意的地方,可以移步阅读

一般来说,使用Environment.getExternalStorageDirectory()获取的是『外置存储』,但是实际上这个并不是很准确。反而回因为这个『外置』而让人困惑:如果有外置 SD 卡的情况...那谁才是外置呢?

看一下这个方法的注释,他解释得很清楚:

Note: don't be confused by the word "external" here. This directory can better be thought as media/shared storage

他说你不要被『外置』这个词搞蒙了,实际上更像是一个『共享』存储器。这样一说其实你就知道了,即便是有外置 SD 卡的情况,两者都属于『外置存储器』。因为这个概念是相对于 App 内部沙盒存储器来说的。
但是这个时候直接调用这个方法获取的,可能并不是 SD 卡的路径。因为用户并没有将 SD 卡设置为『默认存储器』,所以上面这个方法将会得到原本的『共享』存储器。而不是 SD 卡。

我们看一下实现:

public static File getExternalStorageDirectory() {
    throwIfUserRequired();
    return sCurrentUser.getExternalDirs()[0];
}
...

public File[] getExternalDirs() {
    final StorageVolume[] volumes = StorageManager.getVolumeList(mUserId,
            StorageManager.FLAG_FOR_WRITE);
    final File[] files = new File[volumes.length];
    for (int i = 0; i < volumes.length; i++) {
        files[i] = volumes[i].getPathFile();
    }
    return files;
}

getExternalDirs()方法返回了一个分卷列表,然后getExternalStorageDirectory()直接返回了该列表的第一个元素。也就是『默认存储器』了。

那么我们如何获取 SD 卡路径呢?

获取外置 SD 卡路径

/**
* 返回外置存储卡路径
* @param context
* @return 返回存储卡路径
*/
public static String getExtendedMemoryPath(Context context) {
    StorageManager mStorageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
    Class storageVolumeClazz;
    try {
        storageVolumeClazz = Class.forName("android.os.storage.StorageVolume");
        Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList");
        Method getPath = storageVolumeClazz.getMethod("getPath");
        Method isRemovable = storageVolumeClazz.getMethod("isRemovable");
        Object result = getVolumeList.invoke(mStorageManager);
        final int length = Array.getLength(result);
        for (int i = 0; i < length; i++) {
            Object storageVolumeElement = Array.get(result, i);
            String path = (String) getPath.invoke(storageVolumeElement);
            boolean removable = (Boolean) isRemovable.invoke(storageVolumeElement);
            if (removable) {
                return path;
            }
        }
    } catch (ClassNotFoundException | InvocationTargetException  | NoSuchMethodException | IllegalAccessException e) {
        e.printStackTrace();
    }
    return null;
}

这里利用了反射获取 SD 卡的路径。

获取 SD 卡的 UUID

/**
* 获取 SD 卡的 UUID,FAT32 格式为 xxxx-xxxx;NTFS 是更长的 hex 字符串
* @param context
* @return 返回 SD 卡的 UUID
*/
private static String getRealSDCardId(Context context){
    StorageManager mStorageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
        // 如果 API 大于 23,可以直接调用
        if (mStorageManager == null || mStorageManager.getStorageVolumes().size() <= 1) {
            return null;
        }
        StorageVolume sdVolume = mStorageManager.getStorageVolumes().get(1);
        return sdVolume.getUuid();
    }else {
        String storagePath = getExtendedMemoryPath(context);
        if (!TextUtils.isEmpty(storagePath)){
            // 只考虑 FAT 32 格式的情况,TODO 兼容 NTFS 格式
            Pattern pattern = Pattern.compile(PATTERN_GET_SD_CARD_ID);
            Matcher matcher = pattern.matcher(storagePath);
            if (matcher.find()){
                return matcher.group();
            }
        }
    }
    return null;
}

这里还做了版本判断,如果 API 大于 23 的版本,可以直接调用getStorageVolumes()方法,获取所有卷的卷标。

参看

Tika最简单的使用:new Tika().detect(file)

其中新建一个Tiak实例的时候,初始化了默认的文件类型、文件解析类以及文件探测类。
机会大部分工作都是在这里面做的。
由于篇幅有限,我们略过开始的一些调用,让我们看到 Tika 库里的MagicDetector类,它实现了Detector接口。
所以我们的Tika().detect(file1)实际上是调用了这个类的detec()方法哦。
我先简述一下调用链。如果有感兴趣的读者,可以自行 debug 一下调用哦。

new Tika() --> Tika(TikaConfig config) --> Tika(Detector detector, Parser parser)
// config.getDetector(),所以在 TikaConfig 里就有了 Detector,我们进这里看看
TikaConfig.getDefaultConfig() --> TikaConfig()

TikaConfig()这个默认构造方法中中有下面三句比较重要的代码

...
if (config == null) {
    this.mimeTypes = getDefaultMimeTypes();
    this.parser = getDefaultParser(mimeTypes, loader);
    this.detector = getDefaultDetector(mimeTypes, loader);
}
...
  • getDefaultMimeTypes(),最后是得到如下方法的返回值。在下面这个方法中,加载了库中的两个xml文件,前者存储了大部分已知文件类型的签名
MimeTypesFactory.create("tika-mimetypes.xml", "custom-mimetypes.xml")

比如下面是截取的mp4的文件签名:

<mime-type type="video/mp4">
<magic priority="60">
    <match value="ftypmp41" type="string" offset="4"/>
    <match value="ftypmp42" type="string" offset="4"/>
</magic>
<glob pattern="*.mp4"/>
<glob pattern="*.mp4v"/>
<glob pattern="*.mpg4"/>
<sub-class-of type="video/quicktime" />
</mime-type>
<mime-type type="video/mp4v-es"/>
  • getDefaultParser(mimeTypes, loader),这个最后调用到getDefaultParsers(ServiceLoader loader)方法,通过加载器的方式,从库中读取Parse.class文件。
  • getDefaultDetector(mimeTypes, loader),最后调到getDefaultDetectors(MimeTypes types, ServiceLoader loader)方法

在后者里面,看起来只是做了加载库里Detector.class文件的工作,实际上这里的types才是重头戏。MimeTypes类实现了Detector接口,实际上也有一个detect()方法。在getDefaultDetectors(MimeTypes types, ServiceLoader loader)方法中,有如下代码:

List<Detector> detectors =
        loader.loadStaticServiceProviders(Detector.class);
Collections.sort(detectors, new Comparator<Detector>() {
    public int compare(Detector d1, Detector d2) {
        String n1 = d1.getClass().getName();
        String n2 = d2.getClass().getName();
        boolean t1 = n1.startsWith("org.apache.tika.");
        boolean t2 = n2.startsWith("org.apache.tika.");
        if (t1 == t2) {
            return n1.compareTo(n2);
        } else if (t1) {
            return 1;
        } else {
            return -1;
        }
    }
});
// Finally the Tika MimeTypes as a fallback
detectors.add(types);

代码中,先把从库里加载的Detector.class添加进去,然后再把types加到detectors这个列表里去。所以实际上后者保存了诸多types探测器对象。
所以我们后来调用tika.detect(file)的时候,先使用了Dector.class,再使用默认的探测器,也就是那些types
所以我们逐步来看一下MimeTypes.detect(InputStream input, Metadata metadata)方法的实现:

MediaType type = MediaType.OCTET_STREAM;

// Get type based on magic prefix
// 基于 magic prefix 获取文件类型,实际上就是文件首部的一些字节
if (input != null) {
    input.mark(getMinLength());
    try {
        byte[] prefix = readMagicHeader(input);
        type = getMimeType(prefix).getType();
    } finally {
        input.reset();
    }
}
...
  • readMagicHeader(input),获取文件首部一定范围的字节,这个范围是多少呢?在getMinLength()方法里,直接返回了64*1024...真的是魔数
  • 这里调用了getMineType(prefix),就是把文件首部的一定范围的字节传进去,判断类型,这个方法比较重要,我们可以看一下
private MimeType getMimeType(byte[] data) {
    if (data == null) {
        throw new IllegalArgumentException("Data is missing");
    } else if (data.length == 0) {
        // See https://issues.apache.org/jira/browse/TIKA-483
        return rootMimeType;
    }

    // Then, check for magic bytes
    // 检查魔数字节的类型
    // eval 就是判断当前字节和已知文件类型的头部字节是否相等
    MimeType result = null;
    for (Magic magic : magics) {
        if (magic.eval(data)) {
            result = magic.getType();
            break;
        }
    }

    // 如果不相等,那么返回 null
    if (result != null) {
        // When detecting generic XML (or possibly XHTML),
        // extract the root element and match it against known types
        if ("application/xml".equals(result.getName())
                || "text/html".equals(result.getName())) {
            XmlRootExtractor extractor = new XmlRootExtractor();

            QName rootElement = extractor.extractRootElement(data);
            if (rootElement != null) {
                for (MimeType type : xmls) {
                    if (type.matchesXML(
                            rootElement.getNamespaceURI(),
                            rootElement.getLocalPart())) {
                        result = type;
                        break;
                    }
                }
            } else if ("application/xml".equals(result.getName())) {
                // Downgrade from application/xml to text/plain since
                // the document seems not to be well-formed.
                result = textMimeType;
            }
        }
        return result;
    }

    // 之前返回了 null,就假设她是一个文本类型,再使用文本探测器进行探测
    // Finally, assume plain text if no control bytes are found
    // 如果抛异常,那么就返回 application/octet-stream 类型,也就是二进制格式
    try {
        TextDetector detector = new TextDetector(getMinLength());
        ByteArrayInputStream stream = new ByteArrayInputStream(data);
        return forName(detector.detect(stream, new Metadata()).toString());
    } catch (Exception e) {
        return rootMimeType;
    }
}

我们接着来看MimeTypes.detect(InputStream input, Metadata metadata)方法:

...
// Get type based on resourceName hint (if available)
// 根据文件名类获取类型
String resourceName = metadata.get(Metadata.RESOURCE_NAME_KEY);
if (resourceName != null) {
    String name = null;

    // Deal with a URI or a path name in as the resource  name
    try {
        URI uri = new URI(resourceName);
        String path = uri.getPath();
        if (path != null) {
            int slash = path.lastIndexOf('/');
            if (slash + 1 < path.length()) {
                name = path.substring(slash + 1);
            }
        }
    } catch (URISyntaxException e) {
        name = resourceName;
    }
// 这里判断了一下根据文件签名字节获取的类型是否和文件名类型相等,如果不相等,则优先使用文件签名字节类型
    if (name != null) {
        MediaType hint = getMimeType(name).getType();
        if (registry.isSpecializationOf(hint, type)) {
            type = hint;
        }
    }
}

// 根据文件的元数据来获取信息
// Get type based on metadata hint (if available)
String typeName = metadata.get(Metadata.CONTENT_TYPE);
if (typeName != null) {
    try {
// 这里判断了一下前面获取的类型是否和文件元数据给出的相等,如果不相等,则优先使用文件签名字节类型
        MediaType hint = forName(typeName).getType();
        if (registry.isSpecializationOf(hint, type)) {
            type = hint;
        }
    } catch (MimeTypeException e) {
        // Malformed type name, ignore
    }
}

return type;

从这里我们可以看出,最优先的判断标准依旧是文件的签名,也就是文件的首部字节

首先我们的调用来到了MimeTypes.getMimeType(byte[] data)方法,在这里,我们传入了由待探测文件的头部字节组成的字节数组。
在这个方法里面,有如下代码:

// Then, check for magic bytes
MimeType result = null;
for (Magic magic : magics) {
    if (magic.eval(data)) {
        result = magic.getType();
        break;
    }
}

这里的magics是个Magic类的列表,这个列表是在new Tika()语句,也就是构造Tika对象的时候被初始化的。
在当时,程序加载了库里的tika-mimetypes.xml文件,这个文件中存放了大部分的已知文件类型的头部信息、偏移量等。这些文件被加载存储在一个MimeTypes对象里面。
而创建这个对象的时候需要创建一个MimeTypesReader对象,MimeTypesReader继承了DefaultHandler对象,这个对象是用来解析xml文件的处理类。
实际上在MimeTypes文件中有这么一个方法,是在初始化的时候调用的:

void init() {
    for (MimeType type : types.values()) {
        magics.addAll(type.getMagics());
        if (type.hasRootXML()) {
            xmls.add(type);
        }
    }
    Collections.sort(magics);
    Collections.sort(xmls);
}

Magic.eval() --> MagicMatch.eval() --> 
getDetector().detect(new ByteArrayInputStream(data), new Metadata()) // detector 如果为空,那么调用 MagicDetector.parse() 生成 dector
 --> MagicDetecor.detec()

终于到了最终的方法了,让我们一起来看看这个方法的实际实现:

/**
    * 
    * @param input document input stream, or <code>null</code>
    * @param metadata ignored
    */
    // 我们传入的文件会被打开为输入流,而该文件的文件名和长度会被存储在 Metadata 类中,该类实际上是一个 Map 哦
public MediaType detect(InputStream input, Metadata metadata)
        throws IOException {
    if (input == null) {
        // 如果文件流为空,返回默认的文件类型,也就是『二进制文件』
        return MediaType.OCTET_STREAM;
    }

    /**
    * InputSteam 的 mark 是一个空方法,实际上传入的是一个 TikaInputStream 变量,他实现了这个方法
    * 这个方法做的,只是记录读到流哪个的位置
    */
    input.mark(offsetRangeEnd + length);
    try {
        int offset = 0;

        /** Skip bytes at the beginning, using skip() or read()
        * 跳过初始的一些字节,offsetRangeEnd 默认是 0 ,有一些文件的有效识别字符串不在文件的开头,所以需要跳过无效的区域
        * 有一些文件需要跳过的,比如 ISO 镜像类文件可以参看 [Magic Bytes](https://tool.lu/magicbytes/)
        * 这些信息保存在 tika-mimetypes.xml 文件中
        */
        while (offset < offsetRangeBegin) {
            long n = input.skip(offsetRangeBegin - offset);
            if (n > 0) {
                offset += n;
            } else if (input.read() != -1) {
                offset += 1;
            } else {
                return MediaType.OCTET_STREAM;
            }
        }

        // Fill in the comparison window
        // 新建一个缓冲块,大小是(尾偏移 - 首偏移 + 文件长度),首尾偏移都是正向偏移
        byte[] buffer =
            new byte[length + (offsetRangeEnd - offsetRangeBegin)];
        // 读进缓冲块,返回实际上读的是字节数
        int n = input.read(buffer);
        // 递增偏移量
        if (n > 0) {
            offset += n;
        }
        while (n != -1 && offset < offsetRangeEnd + length) {
            int bufferOffset = offset - offsetRangeBegin;
            n = input.read(
                    buffer, bufferOffset, buffer.length - bufferOffset);
            // increment offset - in case not all read (see testDetectStreamReadProblems)
            if (n > 0) {
                offset += n;
            }
        }

        // 如果是正则类型的,则用正则来匹配
        if (this.isRegex) {
            Pattern p = Pattern.compile(new String(this.pattern));

            ByteBuffer bb = ByteBuffer.wrap(buffer);
            CharBuffer result = ISO_8859_1.decode(bb);
            Matcher m = p.matcher(result);

            boolean match = false;
            // Loop until we've covered the entire offset range
            for (int i = 0; i <= offsetRangeEnd - offsetRangeBegin; i++) {
                m.region(i,  length+i);
                match = m.lookingAt(); // match regex from start of region
                if (match) {
                    return type;
                }
            }
        } else {
            // 如果不是,那么逐个字节进行比较
            if (offset < offsetRangeBegin + length) {
                return MediaType.OCTET_STREAM;
            }
            // Loop until we've covered the entire offset range
            for (int i = 0; i <= offsetRangeEnd - offsetRangeBegin; i++) {
                boolean match = true;
                for (int j = 0; match && j < length; j++) {
                    match = (buffer[i + j] & mask[j]) == pattern[j];
                }
                if (match) {
                    return type;
                }
            }
        }

        return MediaType.OCTET_STREAM;
    } finally {
        input.reset();
    }
}

结语

本文章只是捡了 Tika 库中极少部分的代码来分析,在看源码的过程中,深感自己能力不足。
所以本文也难免有错误缺漏,如果有的话,恳请诸君能不吝赐教~

参看

最近遇到一个问题,需要判断视频文件是否是真正的视频文件。
什么意思呢?萤石的摄像头是将视频写入 TF 卡的:

通过萤石云视频平台将TF卡格式化后,程序会采用预占空间的方式预先将1/4的空间作为视频或者图片的存储空间。

然后他预写入的文件是.mp4后缀的,但是是不可播放的文件。所以一旦播放器播放它,可能就会出错了。为了避免这样的情况发生,我们能否在检索视频的时候就识别出无法播放的视频呢?

我一开始的思路是,能否通过判断文件类型的方式来判断是否播放呢?有可能他预格式化的视频文件,虽然后缀是.mp4,但是本质上不是一个视频文件呢?

判断文件类型

在 Java 中,比较常见的用来判断文件类型的库,就是Apache Tika
引入依赖后,使用起来也十分简单:

File file = new File(System.getProperty("user.home")+ "/Downloads/");
        if (file.exists()){
            for (File file1 : Objects.requireNonNull(file.listFiles())){
                if (file1.isDirectory()){
                    continue;
                }
                try {
                    String type = new Tika().detect(file1);
                    System.out.println(file1.getName() + " ======>>> " + type);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

下面是输出结果:

background.svg ======>>> image/svg+xml
new.json ======>>> application/json
mid_top_1.jpg ======>>> image/jpeg
.DS_Store ======>>> application/octet-stream
apache-maven-3.5.4-bin.tar.1.gz ======>>> application/x-gzip
这本来是张 png 图片.mp4 ======>>> image/png
.localized ======>>> application/octet-stream
old.json ======>>> application/json
不学无数 — Java 中 IO 和 NIO - 掘金.pdf ======>>> application/pdf
Layout_Mobile_Whiteframe.ai ======>>> application/pdf
Snipaste_2018-09-22_22-36-21.png ======>>> image/png
background.png ======>>> image/png
hiv00014.mp4 ======>>> video/mp4
SeimiCrawler-master.1.zip ======>>> application/zip
cn_windows_10_business_editions_version_1803_updated_march_2018_x64_dvd_12063730.iso ======>>> application/x-iso9660-image
i_con_permission.png ======>>> image/png
错误.png ======>>> image/png
open_gapps-arm64-9.0-pico-20180923.zip ======>>> application/zip
forPush.sh ======>>> application/x-sh
adbIn.sh ======>>> application/x-sh
Havoc-OS-v2.0-20180923-oneplus3-Official.zip ======>>> application/zip
MockingBot.dmg ======>>> application/octet-stream

可以看到,几乎全部格式都识别出来了。而且我这里有一个『浑水摸鱼』的『家伙』,那就是这本来是张 png 图片.mp4,人如其名。
这个也识别出来了(octet-stream指的是二进制文件)。
那 Tika 究竟是怎么做的呢?让我们来看看源码呗。
篇幅所限,如果你对这个部分有兴趣,可以移步我的另一篇文章Tika 源码浅析

这里假设你已经看过了 Tika 源码...

现在我们知道了 Tika 通过文件的首部字节、文件后缀判断文件的类型。但是这样依旧无法判断视频是否可以播放。
因为在实际使用中,我发现,有一些个视频文件,虽然是无法播放的,但是它们的首部已经被写入了,成为另一个『空视频文件』。
真的蛋疼。如果单纯使用 Tika 的话,显然会有误差。
那么有没有其他应用层面的 trick 呢?

答案就是 FFmpegMediaMetadataRetriever

FFmpegMediaMetadataRetriever 是什么?

之所以说是 trick...是因为 FFmpegMediaMetadataRetriever 是一个获取媒体文件信息的库。
我在使用时发现,如果一个视频只被写了头部,但是没有实际内容的话,该视频是没有编码信息的。
这个想法是来自MediaInfo这个软件。因为我是先用这个软件测试了一遍,发现是可行的。
然后我找到了在 Android 平台可用的一个类似的库,也就是 FFmpegMediaMetadataRetriever

使用

我们引入依赖

implementation 'com.github.wseemann:FFmpegMediaMetadataRetriever:1.0.14'

然后开始使用:

public static List<String> getAvailableVideoList(String SDCardPath){
    if (TextUtils.isEmpty(SDCardPath)){
        return null;
    }

    List<String> pathList = new ArrayList<>();
    FFmpegMediaMetadataRetriever mediaMetadataRetriever = new FFmpegMediaMetadataRetriever();
    File fileList = new File(SDCardPath);
    if (fileList.exists()) {
        try {
            for (File file : fileList.listFiles()) {
                if (file.getName().endsWith("mp4")){
                    mediaMetadataRetriever.setDataSource(file.getPath());
                    mediaMetadataRetriever.extractMetadata(METADATA_KEY_VIDEO_CODEC);
                    pathList.add(file.getPath());
                }
            }
        } catch (IllegalArgumentException iae) {
            iae.printStackTrace();
        }
    }
    return pathList;
}

如果视频文件无效的,FFmpegMediaMetadataRetriever 会抛出一个异常:

java.lang.IllegalArgumentException: setDataSource failed: status = 0xFFFFFFFF

然后我们捕获这个异常,做一些其他的工作就可以。
这个方法就目前的使用来看,是最稳定和准确的方法。
缺点是:

  • setDataSource()过程有一些耗时,实际上测试,256MB 的视频文件,调用一次需要将近 1s 的时间
  • 会抛异常...

还有其他办法吗?

最一开始想的,其实是开一个 VideoPlayer,然后在onError()设一个监听器,如果播放错误就说明该文件无效。
但是经过我自己的评估,这样的性能实际上更差。

也不知道是否有其他更优的方法,如果有的话,还请不吝赐教~


参看

本文发布于我的博客

此文章为「译文」,原文链接:http://www.mergeconflict.net/2012/05/java-threads-vs-android-asynctask-which.html

翻译已获原作者授权。水平有限,如有缺漏,恳请指正,谢谢~

前言

在 Android 开发中,有一个非常重要但是较少被讨论到的问题:UI 的响应。这个问题一部分由 Android 系统本身决定,但更多时候是还是开发者的责任。抛开其他问题而言,解决 Android 应用 UI 响应问题的关键,就是尽可能地让大部分耗时工作转移到后台执行。众所周知,将耗时任务或是 CPU 密集型任务放到后台运行的方法,基本上只有两个:

  • Java Thread
  • Android 原生AsyncTask辅助类

两者不一定能分出个孰优孰劣,因此了解他们各自的使用场景,对您的优化性能是有一定的好处的。

AsyncTask 的使用场景

  • 不需要下载大量数据的简单网络操作
  • I/O 密集型任务,耗时可能几个毫秒以上

Java Thread 使用场景

  • 涉及中等或大量的网络数据操作(包括上传和下载)
  • 需要在后台执行的 CPU 密集型任务
  • 当你想要在 UI 线程控制 CPU 占用率时

还有一个老生常谈的问题就是,千万不要在 UI 线程(主线程)执行网络操作。你需要使用上述两种方式之一来访问网络。

关键点

Java Thread 和 AsyncTask最关键的不同点在于,AsyncTask运行在 GUI 线程¹ 上,所以繁重的 CPU 任务都可能导致 UI 响应性下降。Java Thread 可以拥有不同的线程优先级,使用低优先级的线程来完成非实时运算任务能够很好地为 GUI 操作释放 CPU 时间。这是提高 GUI 响应性的关键点之一。

然而,正如很多 Android 开发者所了解的,你无法在后台线程更新 UI 组件,不然就会抛出异常。这对于 AsyncTask来说并不是什么大事² ,但是当你使用的是 Java Thread,那么你必须在你操作结束的时候使用post()来更新 UI³ 。


译者按原文查找资料注:

  1. AsyncTask必须在主线程加载,其中除了doInBackground(Object [])方法外,其余三个方法都在 UI 线程运行
  2. 基于第一点,AsyncTask可以在其余三个方法中更新 UI 组件
  3. 可以使用view.post()方法来更新 UI 组件,这个方法和使用Activity.runOnUiThread()方法区别不大

参看