安卓初学者入门指南-三-

157 阅读1小时+

安卓初学者入门指南(三)

原文:Android for Absolute Beginners

协议:CC BY-NC-SA 4.0

十四、为 Android 处理视频和电影

近年来,手机应用最繁荣的领域之一是视频。无论是在通勤时观看网飞的流媒体节目,还是在 YouTube 上捕捉猫咪的滑稽动作,或者使用基于视频的聊天和消息应用,视频在安卓系统中从未如此突出。给你的应用添加视频功能非常简单,尽管你应该知道 Android 下的视频有一些奇怪和意想不到的地方。

在这一章中,我们将探讨向应用添加视频内容的最简单方法,然后花时间学习更广泛的视频工具集,如果你打算认真对待 Android 视频的话。在本书的后面,我们还将提到使用 Android 内容供应器机制的视频选项。

回放视频

就像音频和声音一样,Android 提供了一系列将视频回放引入应用的方法。事实上,这些方法中的一些是你在上一章中已经使用过的类和框架,比如媒体框架。

播放视频有其独特的方面,其中最重要的是使用专用的小部件 VideoView,用于实际显示视频和控制视频在播放过程中的一些行为,以及用户在播放时对视频的一些控制。

在 Android 中处理视频非常简单。虽然您可以构建复杂的层次,但从最基本的开始是理解视频播放过程中发生的事情的机制的好方法,也是让自己熟悉更复杂的方法对您隐藏的基本构件的好方法。

我们将通过浏览一个示例应用来开始探索视频回放,您可以在Ch14/VideoPlayExample项目文件夹中找到该示例应用。

设计基于视频视图的布局

为了显示视频以便回放,我们需要一个合适的带有 VideoView 对象的活动。清单 14-1 显示了 VideoPlayExample 应用的布局,它就有这样一个视频视图。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayoutxmlns: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">

    <VideoView
        android:id="@+id/video"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintTop_toBottomOf="@+id/stopButton"
        tools:layout_editor_absoluteX="0dp" />

    <Button
        android:id="@+id/startButton"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_above="@+id/stopButton"
        android:onClick="onClick"
        android:text="Start Video &​#​127910​;"
        android:textSize="24sp"
        app:layout_constraintTop_toTopOf="parent"
        tools:layout_editor_absoluteX="16dp" />

    <Button
        android:id="@+id/stopButton"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:layout_alignParentLeft="true"
        android:layout_centerVertical="true"
        android:onClick="onClick"
        android:text="Stop Video &​#​127910​;"
        android:textSize="24sp"
        app:layout_constraintTop_toBottomOf="@+id/startButton"
        tools:layout_editor_absoluteX="16dp" />

</androidx.constraintlayout.widget.ConstraintLayout>

Listing 14-1Layout XML including VideoView object for VideoPlayExample

回顾我们的布局,您会注意到我们有如下三个小部件:

  1. “开始视频”按钮,其startButtonandroid:idandroid:onClick属性设置为“onClick”

  2. 一个“停止视频”按钮,stopButtonandroid:idandroid:onClick属性设置为“onClick ”,就像startButton一样

  3. 一个VideoView小部件,带有一个videoandroid:id

我们使用了一个ConstraintLayout布局,并将startButton设置为限制在父窗口的顶部(也就是活动窗口的顶部)。stopButton被约束对齐到startButton的底部,视频VideoView被约束对齐到stopButton的底部。在显示任何视频之前,最终的布局看起来很像图 14-1 中的图像。

img/499629_1_En_14_Fig1_HTML.jpg

图 14-1

VideoPlayExample 应用的可视布局

布局故意非常直接,以便访问视频文件、播放视频文件等的逻辑更加平易近人。鉴于两个按钮都使用了android:onClick="onClick"属性,您可能已经能够猜出基本结构了。

在代码中控制视频回放

查看伴随我们布局的 Java 逻辑,您将立即发现一个模式,它与我在第十三章中介绍的音频和声音示例相似。正如我们在 AudioPlayExample 和 AudioStreamExample 应用中看到的那样,许多控制逻辑都围绕着使用 onClick()方法来驱动活动行为。我们的 Java 代码如下所示,如清单 14-2 所示。

package org.beginningandroid.videoplayexample;

import androidx.appcompat.app.AppCompatActivity;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.MediaController;
import android.widget.VideoView;

public class MainActivity extends AppCompatActivity {
    private VideoView vv;
    private MediaController mc;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

    }

    public void onClick(View view) {
        switch(view.getId()) {
            case R.id.startButton:
                doPlayVideo();
                break;
            case R.id.stopButton:
                doStopVideo();
                break;
        }
    }

    private void doPlayVideo() {
        vv =(VideoView)findViewById(R.id.video);

        mc = new MediaController(this);
        mc.setAnchorView(vv);

        vv.setMediaController(mc);
        vv.setVideoURI(Uri.parse("android.resource://" + getPackageName() + "/" + R.raw.video_file));
        vv.requestFocus();
        vv.start();
    }

    private void doStopVideo() {
        if (vv != null) {
            vv.stopPlayback();
        }
    }

}

Listing 14-2The Java logic for video playback

Note

这个代码示例使用了一个名为“video_file.m4a”的视频文件。如果您出于任何原因需要访问原始视频文件,可以从beginningandroid.org网站获得。

从我们的MainActivity开始,你会看到我们创建了两个对象。第一个是名为vvVideoView对象,稍后将用于绑定到展开布局的<VideoView>元素。第二个是一个MediaController对象mc,我们很快就会谈到它。onCreate() override 执行扩展布局的基本操作,仅此而已。

接下来,您将看到onClick()方法,就像在音频示例中一样,它将一个View作为参数,然后使用一个基于视图的android:idswitch语句来确定哪个按钮被点击了:startButtonstopButton。这与第十三章中的例子使用的模式非常相似——你可以看出这是我反复发现的一个有价值的模式!

如果startButton被检测为View(按钮)被点击,那么doPlayVideo()方法被调用。该方法首先确保vv VideoView对象绑定到VideoView UI 小部件,使用现在已经很熟悉的调用findViewById()的技术,并使用布局中VideoView持有的“视频”的 android:id 的R.id.video样式表示。

接下来,我们实例化新的MediaController对象mc,然后立即调用setAnchorView()方法。这将绑定MediaController,并允许它呈现一组浮动控件,当在应用中使用时,这些控件将出现在VideoView对象上。当您运行VideoPlayExample应用时,您将能够看到其中的一些控件。同样,我们向VideoView指出mc MediaController负责管理vv VideoView中显示的任何视频的回放的某些方面。

vv.setVideoURI()的调用构建了一个兼容的 URI,它引用了一个名为video_file.m4v的视频,该视频已被放入项目的 raw 文件夹中。要想看到完全成型的 URI 是什么样子,可以在 Android Studio 中调试代码并设置断点。

视频的 URI 传递给了VideoView,我们调用requestFocus()来确保小部件获得焦点,然后通过调用start()方法开始回放。假设所有的工作都按照描述进行,你应该会看到视频开始播放,如图 14-2 所示(至少是静态截图)。

img/499629_1_En_14_Fig2_HTML.jpg

图 14-2

视频播放示例在播放过程中显示视频

我们逻辑的最后一部分是doStopVideo()方法,它被调用以响应检测到用户点击stopVideo按钮的onClick()方法。在doStopVideo()中,我们首先检查以确保VideoView对象vv已经被实例化,然后调用它的stopPlayback()方法。

除了我们非常基本的显式控件之外,如果您在播放期间触摸VideoView范围内的任何地方,您还将能够看到MediaController's浮动 UI 元素出现在视频上。MediaController播放控件将出现,如图 14-3 所示。

img/499629_1_En_14_Fig3_HTML.jpg

图 14-3

回放期间视图中的 MediaController 控件

理解关键视频概念

掌握了视频播放的基本技巧后,你可以通过几个途径来扩展你的视频技能。您可以尝试活动和 VideoView 和 MediaController 视图对象的进一步组合,以精确布局您想要的视频界面类型。您还可以将视频片段组合成更复杂的活动,例如应用或游戏的视频过场动画开场序列。

另一条你可以也应该同时采取的途径是,确保你在数字视频的基础方面有良好的基础,这样你就可以在构建你的 Android 应用时,对内容、大小、质量和用途做出好的选择。接下来,我们将介绍您应该了解的关键概念。

比特率

我们在第十三章讨论音频时引入了比特率的概念。从概念上讲,比特率代表视频的同一个方面——在任何给定的时刻,可用于代表视频各个方面或视频帧的数据量。视频的比特率通常由(至少)两个因素的组合来计算。首先,视频的分辨率是多少,换句话说,控制视频帧实际物理高度和宽度的水平和垂直像素密度是多少?第二,在整体分辨率中,有多少位信息用于描述每个给定像素的颜色、色调和饱和度?

一般来说,正如我们在音频中看到的那样,更高的比特率意味着更好的保真度,这通常会导致观看视频的人感受到更好的质量。权衡也是一样的:更高的比特率需要更多的存储,因为每个视频帧要编码更多的信息。这就引出了对帧率的讨论。

帧频

视频的帧速率几乎是不言自明的。以什么样的速率显示图像帧,人类视觉暂留效应会让我们误以为图像在移动?Android 支持的大多数视频编解码器(在下面讨论)默认为每秒 30 帧。对较低和较高的帧速率有一些支持,但通常只在特殊情况下使用。

编解码器

与音频领域形成鲜明对比的是,Android 支持大量的音频编解码器,而在视频领域,Android 设备支持的视频编解码器和视频容器格式并不多。造成这种情况的原因是既得利益、专利法、行业卡特尔以及供应商和许可证持有者的可疑优先权,这些因素几乎从来没有将用户放在第一位。

我将很快提供我对这些激励因素和限制的看法,但在撰写本文时,这里是对 Android 中视频编解码器支持的客观看法。现代 Android 设备和 Android 操作系统本身支持以下编解码器的视频回放:

  • H.263:由视频编码专家组开发,旨在成为一种低比特率压缩格式。

  • H.264(基线和主要配置文件):旨在提高 H.263 的质量,同时降低比特率和文件大小,H.264 近年来一直主导着视频编解码器领域。

  • H.265:作为 H.264 的继承者,H.265 也被称为 HEVC,或高效视频编码方案。它的设计者认为它将接替 H.264 成为最受欢迎的编解码器,但它的专利阻碍意味着许多在线、媒体和技术公司寻求不同的道路,专注于 AV1 这样的编解码器(在下文中讨论)。

  • MPEG-4 SP:这是一种特殊的编解码器,不要与容器格式 MPEG-4 混淆。您会发现自称为 MPEG 或 MPEG-4 的文件实际上具有 H.263、H.264 或 H.265 编码的视频。他们声称是基于容器格式的 MPEG 或 MPEG-4(其全称是 MPEG-4 part 14),而不是视频编解码器。下面将详细介绍这种区别。

  • VP8:由 On2 Technologies 创建,专门设计为现代使用的更有效的编解码器。当 On2 Technologies 被 Google 收购时,该编解码器作为一个开放的、免版税的编解码器重新发布。

  • VP9:On2 Technologies 的 VP8 编解码器的后继者,提供增强的编码和解码性能。

  • AV1:许多公司在技术和媒体领域的最新合作。AV1 最大的优势在于它是一种“无负担”的格式,这意味着使用它不需要向专利持有者支付版税。

这似乎是一个范围广泛的视频编解码器列表。事实上,这是一个非常有用的设置,可以让你处理各种视频。然而,今天有更多的视频编解码器在使用,包括许多非常流行的,许多容器格式,Android 也只支持其中的一部分。大多数限制和不兼容性与技术无关,而与专利和许可制度以及来自电子、电影和娱乐等行业的一些利益方的反复无常的本性有关。

了解视频容器和子编解码器的复杂世界

为了更好地为未来以视频为中心的开发做准备,深入研究视频容器、格式、编解码器和字幕绝对是值得的,这样您就可以了解视频到底是什么,以及您可能使用和分发的媒体内容中包含和不包含什么。

您可能认为是视频文件的文件,即带有类似。mp4, .m4v, .avi, .mov, .mkv等等——是潜在的许多视频、音频和字幕资源的表示,打包到一个容器中。容器命名法用于表示文件的专有、行业标准或开放格式,这意味着文件的消费者可以找出视频内容、音频内容和字幕在文件中的位置。

图 14-4 给你一个视频容器格式及其包含的媒体的直观概述。

img/499629_1_En_14_Fig4_HTML.jpg

图 14-4

视频容器格式的概念视图

如您所见,您的视频文件实际上是以下四个方面的组合:

  1. 整体容器格式,Android 支持 MPEG-4 part 14、Matroska、3GPP 和 WebM,但不支持其他流行的容器,如 AVI

  2. 使用支持的编解码器编码的一个或多个视频(如本章前面所述)

  3. 使用支持的音频编解码器编码的一个或多个音频流(如第十三章所述)

  4. 一个或多个字幕/说明资源

这些因素结合在一起,让你作为一个热衷于视频的开发者的生活变得更加复杂。如果复杂性到此为止,您可能会很高兴使用这些因素。但是还有一个更复杂的问题,那就是 Android 在给定的容器格式中支持的视频和音频编解码器的组合。不幸的是,事情并不都是即插即用的,您可以使用这里列出的选项的任何组合。相反,您应该始终参考 Android 开发者参考文档中当前支持的 Android 组合,您可以在 https://developer.android.com/guide/topics/media/media-formats 找到该文档。

那么,当出现您希望在应用中使用的优秀视频材质,而这些材质恰好位于不支持的容器中,或者当您希望使用不支持的编解码器或容器格式和编解码器的组合时,您该怎么办呢?很高兴你问了!在下一节中,我们将介绍一系列工具,您可以考虑将这些工具添加到您的开发人员工具包中,以提高您的视频内容创建、编辑和管理技能。

扩展开发人员视频工具集

作为一个狂热的 Android 开发人员,你会很快意识到像 Android Studio 这样的工具主要是围绕编码、布局和将代码转化为工作应用的工作流程而设计的。Android Studio 没有配备图形或视频创建或编辑等高级工具,这意味着作为一名开发人员,要构建您的选项,您应该寻找一些工具来补充您的 IDE,这些工具在视频编辑等方面表现出色。

查看您可以使用的视频编辑工具

当涉及到视频制作、视频编辑和视频内容管理领域时,作为开发人员,您可以选择的软件选项多得惊人。其中既包括传统的安装软件,也包括一些非常强大的工具的托管/云版本。

在决定采用和使用何种工具时,您需要问自己几个问题,关于您计划如何在应用中使用视频,因为这将相应地影响工具的选择。要问的一些关键问题如下:

  1. 我是将视频嵌入到我的应用中,从而将它作为原始文件或素材文件包含在内,还是从在线源进行流式传输?

  2. 我会创建和录制我自己的视频,还是使用其他来源的视频,并简单地合并我以这种方式获得的任何内容?

  3. 我是否需要管理一个视频或视频内容库,或者我的视频只是一个非常小的内容集,我可以以特别的方式进行管理?

  4. 我是否希望编辑、更改或以其他方式改变应用中包含的视频?

仔细考虑这些问题可以帮助您避免选择在您需要的领域没有优势的工具,更不用说通过选择更好的工具来帮助您避免金钱成本和开发时间。

我在下面概述了一系列当代的、流行的、免费的和商业的工具,您应该自己去探索,并判断哪一个或哪些工具最符合您的需求。然后,我使用 hand brake——一个非常受欢迎的免费开源工具——完成了 Android 开发人员最常见的视频工作流程之一:编辑现有视频以便在 Android 上播放。

流行的开源视频编辑套件

这个开源工具列表并不详尽,但涉及了一系列流行的视频编辑工具:

  • FFmpeg:一个非常强大的库和一组命令行工具,用于所有形式的视频编辑、代码转换等。在本书和其他地方提到的一些其他工具中,FFmpeg 也通常被认为是做艰苦工作的引擎。

  • VideoLAN VLC:一个视频播放、编辑和通用的万能工具,VLC 受益于可以在任何可以想象的操作系统平台上使用,包括 Android 本身。

  • 手刹:一个单一用途,非常强大的工具。手刹的主要目标是提供最好的视频转码工具。它很好地做到了这一点——事实上,我们将在本章的后面探讨它的用法。

  • Kdenlive:一个非常出色的视频编辑和管理套件,也是最成熟的套件之一。

  • OpenShot:比 Kdenlive 更新,但目标是相同的综合视频编辑和管理工具集。

流行的商业视频编辑套件

  • Adobe Premiere:作为 Premiere 的长期用户,我可以告诉你,它有丰富的功能,与其他 Adobe 产品配合得相当好。它的定价通常会让爱好者或早期开发人员转向别处。

  • Final Cut Pro: Apple 的主要商业产品,一个非常强大的工具,但显然是为 macOS 社区设计的。

  • Lightworks:用于严肃视频编辑和管理的严肃工具,Lightworks 诞生于电影行业,是该领域事实上的标准。

  • da Vinci Resolve:Resolve 是一款更新、非常有趣的产品,采用“免费增值”模式,入门级是免费的(而且非常好),付费版本提供更复杂的特性和功能。

  • 苹果 iMovie:如果你是一台 Mac 电脑的所有者,那么你的购买包括捆绑的 iMovie 软件。虽然这是针对家庭和消费者使用的,但它实际上是一个很好的产品,可以满足您最初的电影编辑和管理需求。很明显是 Mac 专用的。

视频编辑手刹介绍

为了让您熟悉视频编辑的工作流程,让我们回顾一下您可能会执行的最简单的任务,即将现有视频从某种任意的容器格式和视频编解码器转码为支持的容器和编解码器,以便在您的 Android 应用中使用。这类工作的突出工具之一是手刹,它类似于视频转码的瑞士军刀。其他工具也可以完成令人钦佩的代码转换工作,但手刹是最容易精通的工具之一。

下载和安装手制动器

我喜欢手刹的原因之一是它适用于所有主要的操作系统。无论你使用的是 macOS、Windows 还是 Linux,你都可以在你的系统上运行原生版本的手刹。

前往位于 https://handbrake.fr/ 的手刹网站,下载适用于您的操作系统的软件包或软件包配置。在接下来的例子中,我们将使用 Ubuntu (Debian) Linux 安装,其中包括额外的配置包管理的步骤,以便为您下载手刹。之后的步骤基本上是相同的,不管你的平台是什么。

在 Debian 或基于 Debian 的系统上,按照网站上的说明将手刹库添加到您的库配置中,然后运行

sudo apt install handbrake-gtk

您也可以选择同时安装命令行版本,例如:

sudo apt install handbrake-gtk handbrake-cli

对于基于 RPM 的 Linux 系统,比如 Fedora、CentOS、Red Hat 等等,使用等效的yum包管理命令。对于 Windows 用户,使用可下载的安装程序安装手刹。对于 macOS 用户,下载手刹DMG镜像,双击挂载即可。然后将手刹应用拖到应用文件夹中。

跑步和使用手刹

根据您的操作系统启动合适的手刹,您应该会看到如图 14-5 所示的主屏幕。

img/499629_1_En_14_Fig5_HTML.jpg

图 14-5

手刹的主屏幕

正如您所料,在主屏幕的起点,您可以探索许多选项。为了演示手刹的主要功能,我们将选择一个现有的视频文件,以便我们可以将其转码为适合 Android 使用的格式。要选择文件,请单击手刹窗口左上角的“打开源代码”按钮。在我的例子中,我将选择一个我在另一个设备上捕获的文件,在我的例子中它被称为IMG_9111.MOV。文件选择器如图 14-6 所示——如果你按照这些说明使用你自己的视频文件,显然你自己的源文件名会有所不同。

img/499629_1_En_14_Fig6_HTML.jpg

图 14-6

选择要在手制动中转码的源视频文件

手刹最好的功能之一是它能够确定源文件的容器格式、视频和音频的编解码器等等。您不会被要求指定这些输入参数中的任何一个,尽管您可以根据自己的专业知识来覆盖手制动中的内容。

选择源文件后,您主要关心的是指示 HandBrake 将视频代码转换为基于 Android 支持的编解码器的格式,并将其打包到也支持的容器格式中。手刹在这方面大放异彩,它预装了一系列现有选项,你只需点击几下就可以选择,以满足 Android 规定的格式和编解码器支持要求。

要让手刹使用 Android 的预配置目标格式,点击Preset字段右侧的向右箭头。这会弹出一个菜单,选项包括General, Web, Devices,等等。选择Devices子菜单,将显示进一步的子菜单,类似于图 14-7 所示。

img/499629_1_En_14_Fig7_HTML.jpg

图 14-7

手制动装置子菜单中的预配置选项

如你所见,这里显示了许多预配置选项,适用于许多设备,而不仅仅是 Android 设备。我们对整体分辨率和帧速率之间的良好折衷感兴趣,以确保我们得到的转码视频不会太大。在我的例子中,我选择了Android 720p30预配置选项,它将自动为我的转码视频设置这些质量:

  1. Android 支持的容器格式:在本例中,这将是 MPEG-4 容器格式。

  2. Android 支持的视频编解码器:Android 720p30预先配置的设置使用 H.264 作为视频编解码器。

  3. Android 支持的音频编解码器:AAC 将被用作音频编解码器。

  4. 分辨率:这将允许视频每帧最多 720 个“渐进”扫描线(因此简称为 720p)。

  5. 帧率:每秒 30 帧。

我将指定一个目标文件来保存转码后的视频,在本例中称为IMG_9111.m4v。点按“开始”按钮开始转码,当转码完成时,手刹会显示一条大胆的消息,告诉你放下咖啡,因为你转换的视频已经准备好了。

我的最终产品是一个 1.1 MB 大小的视频文件,在 Android 设备上播放时质量仍然非常好。这与 14 MB 的原始视频文件相比非常有利。

Android 视频更进一步

通过回顾本章开始时介绍的 VideoPlayExample 应用,您可以看到我的示例视频代码转换看起来有多好,以及视频播放的基本机制。我鼓励你用这个例子做更多的事情,感受视频在 Android 下的优势和挑战。首先,尝试替换你自己的视频,包括你用手刹或其他工具编辑和转码的视频。通过使用VideoPlayExample作为各种视频实验的简单测试工具,您可以开始自己判断哪些方法适合您的应用。

第二件要做的事情是将VideoPlayExample转换成使用 URI 并流式传输其源文件。从第十三章的音频流示例中复制逻辑,或者在本书的网站 www.beginningandroid.org/ 查看更多视频流回放的示例代码。

摘要

在本章中,我们介绍了 Android 应用的视频世界,并展示了如何将基本的视频回放添加到您的应用中。您还看到了如何将其他视频编辑和创作软件添加到您的开发工具包中,从而极大地扩展了您对视频的处理能力。

十五、通知简介

历史上几乎每个操作系统都发明了一种机制来提醒你有趣的、重要的或紧急的通知。从计算“赞”到低电量警告,通知几乎是每个设备体验中无处不在的一部分。Android 提供了一个当代的通知生态系统,并在其通知框架中进一步发展了一些非常有用的功能。

每个 Android 设备,从手机到平板电脑到车载娱乐系统,都拥有一系列通知机制,我们将在本章中探讨这些机制。如果你使用过 Android 设备,你会对屏幕顶部或锁定屏幕上出现的托盘图标很熟悉。您还会看到弹出对话框通知,这有其优点和缺点。

除了软件的通知功能,Android 还提供了各种硬件选项,可以在派对上帮助通知。无论是通过振动来增加新通知的动力,还是通过触觉反馈来确保用户获得即时的触觉反馈,Android 都通过一个全面的框架为您的通知需求提供了统一战线。

配置通知

在使用应用的正常过程中,它有很多方法来呈现新的或重要的信息,以抓住用户的注意力。有时会发生一些值得注意的事情,但用户已经离开了应用,或者它在后台或暂停。对于像没有 UI 的服务这样的应用,没有正常的面向用户的可见性,所以当试图被注意时,在那些情况下存在额外的障碍。

Android 通过其NotificationManager系统服务处理这些情况以及更多情况。通过将一个适当结构化的参数传递给一个getSystemService()方法调用,您就可以通过您的应用使用NotificationManager。这可以像下面的代码片段一样简单:

getSystemService(NOTIFICATION_SERVICE)

getSystemService()的调用将为您提供一个结果NotificationManager对象,然后您可以访问它提供的通知管理方法。你将使用的一些最常见的NotificationManager对象的方法有

  1. 顾名思义,这是一种根据你认为值得用户注意的任何触发或情况来激活通知的方法。它将一个Notification对象作为参数,该参数在有效负载中携带您的通知的细节——文本、图像等等,以及您希望 Android 通知基础架构警告用户的方式。

  2. cancel():使用此方法来消除通知。Android 还可以取消通知来响应某些用户动作,包括像“滑动以消除”这样的手势。

  3. cancelAll():核选项!当您只想让 NotificationManager 对象激活的所有通知都运行时,调用cancelAll()

使用通知对象自定义通知

在通知用户时,Android 中的默认通知行为能够完成你想要的大部分事情。但是有时候你想多走一步,真正地寻求用户的注意。对象拥有增强和定制通知的方法。

了解增强通知的新旧方式

Android 的通知已经随着时间的推移而发展,比如有最初的(旧的)增强通知的方法,使用单独的附加方法来调整和放大您的工作,以及最近的方法,使用一个NotificationBuilder对象来一次性处理所有的定制。我将展示这两种方法,并指出,如果您的目标是版本 7 之前的旧版本 Android,旧方法更有可能满足您的需求。

给通知添加声音

让我们从旧的/传统的方法开始我们的通知探索,特别是 Android 对许多不同类型的通知的声音支持。通过在基本 Android 级别利用一系列用户可配置的声音,如果您不想管理音频资源,您可以避免管理音频资源。您可以让您的Notification对象通过调用其.defaults()方法来利用设备的默认声音(无论是否由用户配置),如下所示:

Notification myNotification = new Notification(...);
myNotification.defaults = Notification.DEFAULT_SOUND;

您可以采取额外的步骤,通过使用对音频资源的Uri引用来提供您自己的声音,无论它是您提供的原始或素材管理的文件,还是对 Android 附带的许多声音之一的引用。

下面显示了一个使用普通 Android“ka limba”声音的示例,通过 ContentResolver 类使用该资源的 Uri 并相应地分配声音:

Notification myNotification = new Notificiation(...);
myNotification.sound = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE +
                                                     "://" +
                                                     getPackageName() +
                                                     "/raw/kalimba");

由于有多种触发通知的机制,因此了解优先级的等级很重要。使用.sound()分配的任何声音通知(或其他使用其相关方法的通知形式)将被使用.defaults()方法设置的任何等效通知覆盖,如果这样的调用包含通知类型的参数,如声音的标志DEFAULTS_SOUND。无论调用这些方法的顺序如何,都会发生这种情况。

在 Android 新的通知世界中,你可以通过建立一个NotificationBuilder对象来设置你想要使用的声音等属性,最后当你设置好所有想要的属性后,让Notification使用NotificationBuilder。使用新方法设置声音的等效工作如下所示:

Notification.Builder myBuilder = new Notification.Builder(this, ...)
myBuilder.setSound(Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE +
                                                     "://" +
                                                     getPackageName() +
                                                     "/raw/kalimba"));

使用设备灯通知

很少有安卓手机或平板电脑缺少内置 LED 灯作为前置显示屏的一部分。这种光可以有很多用途,包括作为通知用户通知的另一个载体(而不仅仅是让我,你信任的作者,在凌晨 3 点保持清醒)。通过基于通知对象的配置进行配置,可以以多种方式控制 Android 设备的内置灯:

  1. 那个。lights()方法在传递一个布尔值 TRUE 值时激活 LED。

  2. 在支持的设备上,您可以通过 ledARGB 参数和您希望使用的基于 RGB 的颜色的匹配十六进制代码来更改 LED 的颜色。

  3. 使用 ledOnMS 和 ledOffMS 值闪烁和循环灯光,以毫秒表示打开和关闭时间。

使用Notification.Builder的较新 Android 版本的等效方法是.setLights()方法。您可能开始猜测如何使用构建器方法从使用基本通知对象的旧方法中推断出新方法,反之亦然。

Note

通知。Android 8 引入了 Builder()方法,旧的通知样式已经过时。当 API 级别超过 24 时,应该总是使用通知。Builder()并让 Android 的兼容性库处理旧版本上的行为。

以及您希望使用的特定通知花体,确保设置Notification.flags字段以包含Notification.FLASH_SHOW_LIGHTS标志。在使用单色 LED 的基本设备上,您可能会发现您选择的颜色并不适用,相反,设备会改变 LED 的亮度。对于具有支持多色输出的 led 的设备,如果制造商没有为通知类提供必要的智能来控制颜色,也会出现这种情况。

还有一些设备没有用于通知的 LED,包括电视、Android 自动系统和 Android 的一些嵌入式应用。鉴于这种多样的设备状况,你应该考虑将闪烁 LED 通知作为一种额外的奖励,而不是一种吸引注意力的重要方法。

摇晃它!

你的用户拥有比视觉和听觉更多的感官,你可以用它们来吸引他们的注意力。当闪烁的灯光和朗朗上口的声音还不够时,你可以(一语双关)转向振动。Android 的原始通知模型包括一个默认标志,允许使用设备范围的默认设置来改变事情:

myNotification.defaults = Notifcation.DEFAULT_VIBRATE;

新的通知方法使用 Builder 对象上的.setVibrate()方法来达到同样的效果。

要让任何基于振动的通知实际触发物理振动,您需要在清单中拥有以下权限:

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

当默认振动不够时,您可以通过.vibrate().setVibrate()方法执行自定义振动,提供一个以毫秒为单位的long[]值,例如:

new long[] {1000, 500, 1000, 500, 1000}

是一个有效的序列,将触发三次一秒钟长的振动,每次振动之间有半秒钟的间隔。

添加通知图标

到目前为止,我们介绍的通知方法都是为了抓住用户的注意力。Android 还提供了使用图形的能力,以图标的形式,向用户提供关于通知的更多信息和上下文。

图标是图像文件,因此被认为是用于 Android 资源管理目的的可绘制图标。您需要提供一个contentIntent值,当用户实际点击您在通知中提供的图标时,该值作为PendingIntent传递。这个PendingIntent充当一个占位符和延时功能,允许一个意图被准备好,以便它可以在以后被一个活动或另一种技术触发。

Understanding Pendingintent

挂起内容是 Android 使用的一种机制,用于提前向设备上运行的另一个应用或服务传递令牌或权限。有了 PendingIntent,接收应用就可以在将来的某个时候运行从您的应用中选择的一段代码(无论您的应用本身是否正在运行),并使用您的应用的权限来这样做。

除了能够添加您选择的图标和相关的contentIntent,您还可以添加带有tickerText属性的简短文本描述。此文本应该用于您希望用户看到的通知文本的最重要部分,如发送消息的联系人姓名、电子邮件的主题、社交媒体帖子的标题等。setLatestEventInfo()方法允许您在一次调用中指定全部三个iconcontentIntenttickerText

无论您使用的是旧的还是新的通知模型,这种PendingIntent方法都适用。

不同 Android 版本的图标大小

添加图标可以让你把图标调整到你想要的艺术水平,但是对于不同版本的 Android 你需要记住一些注意事项,因为这些会影响所支持的图标图像的分辨率。

为了最大化您可以支持的 Android 设备范围及其相关的通知样式和大小,您应该创建至少四个代表您的图标的 drawables:

  1. 一个 12 像素乘 19 像素的边界框,包含一个 12 像素的正方形图标,用于低密度屏幕。这个图标将被放置在 res/drawable-ldpi-v9 项目文件夹中。

  2. 一个 16 像素乘 25 像素的边界框,包含一个 16 像素的正方形图标,用于中等密度的屏幕。这个图标将被放置在 res/drawable-mdpi 文件夹中。

  3. 一个 24 像素乘 38 像素的边界框,包含一个 24 像素的正方形图标,用于高密度、超高密度和超高密度屏幕。该图标将放置在 res/drawable-hdpi-v9、res/drawable-xhdpi-v9 和 res/drawable-xxhdpi-v9 文件夹中。

  4. 对于 2.3 之前的所有 Android 版本,这是一个 25 像素的正方形(不考虑这些旧设备上的实际屏幕密度)。这将被放置在 res/drawable 资源文件夹中。

这些变化会随着时间的推移而改变,正如 Android 支持不同分辨率的建议方法一样,所以请务必在 Android 开发者网站上查看图标样式的详细信息。该网站包括一些有用的信息,关于放大和缩小你决定不会或不能为你的应用提供任何预期的保真度水平的可绘制图形。如果你跳过了这些图标中的一个,不要惊慌,但是要注意,Android 会尝试缩放你的另一个图标来填补空白,并且最终的屏幕图像可能不会很棒。

添加信息的浮点数

通知还有最后一个变化,你可能已经看到了,也可能依赖于它。这是应用启动器图标上的“数字”,它提供了给定应用的类似通知或未读/未回复通知的数量。

浮点数调整是通过使用Notification对象的一个名为number的公共数据成员来实现的,您可以将它设置为您希望的任意数字。它将在图标的右上角或左上角显示为应用启动器图标的覆盖图(取决于设备上的区域设置和从右到左或从左到右的约定)。默认情况下,这个值是不设置的,并且被 Android 忽略,除非你为 number 设置一个值。

在 API 级别 26 中引入通知通道

随着 API 26 的出现,Android 抛弃了所有应用和服务的公共通知空间的概念,引入了通道的概念。通道的目标是允许用户(以及隐含的应用)将通知分组并划分到不同的组中,然后以不同的方式对待这些组。

这方面的一个经典用例是让一些通知被认为是信息性的,在“正常”时间显示,但在用户指定一个勿扰时段时隐藏。其他通知可以分配给“紧急关注”或“紧急情况”的频道,并以不同的方式处理。

随着谷歌不断调整和改变通知格局,渠道概念在现实生活中的应用并不全面。然而,作为一名开发人员,如果您在 Android、10.0、11.0 或更高版本的新设备上使用任何形式的通知,您需要考虑这一点。

本章后面的NotificationBuilderExample展示了在新旧 API 级别支持的新旧 Android 设备上定义和使用通道并处理行为是多么简单。

实际通知

现在,您已经介绍了 Android 许多版本所依赖的最初的、仍然有用的通知概念。让我们来看看在NotificationBuilderExample应用中使用的通知,您可以在ch15/NotificationBuilderExample项目文件夹中找到它。

我使用了简单的布局,所以为了节省空间,这里省略了它的 XML。可以看到图 15-1 中的 UI。

img/499629_1_En_15_Fig1_HTML.jpg

图 15-1

基本的 NotificationBuilderExample 布局,不显示任何通知

创建通知的支持逻辑

NotificationBuilderExample的核心是在与 UI 交互时给用户带来通知的代码,如清单 15-1 所示。

package org.beginningandroid.notificationbuilderexample;

import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.NotificationCompat;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.view.View;

public class MainActivity extends AppCompatActivity {
    private static final int NOTIFICATION_ID=12345;
    private static final String MYCHANNEL = "";
    private int notifyCount = 0;
    private NotificationManager myNotifyMgr = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        myNotifyMgr = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
        if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.O &&
                myNotifyMgr.getNotificationChannel(MYCHANNEL)==null)
                    { myNotifyMgr.createNotificationChannel(new NotificationChannel(MYCHANNEL,
                            "My Channel", NotificationManager.IMPORTANCE_DEFAULT));
                    }
    }

    public void onClick(View view) {
        switch(view.getId()) {
            case R.id.notify:
                raiseNotification(view);
                break;
            case R.id.clearNotify:
                dismissNotification(view);
                break;
        }
    }

    public void raiseNotification(View view) {
        Intent myIntent = new Intent(this, NotificationFollowon.class);
        PendingIntent myPendingIntent = PendingIntent.getActivity(MainActivity.this, 1, myIntent, 0);

        NotificationCompat.Builder myNotifyBuilder = new NotificationCompat.Builder(MainActivity.this, MYCHANNEL);

        myNotifyBuilder.setAutoCancel(false);
        myNotifyBuilder.setTicker("Here is your ticker text");
        myNotifyBuilder.setContentTitle("An Android Notification");
        myNotifyBuilder.setContentText("Notice This!");
        myNotifyBuilder.setSmallIcon(R.drawable.wavinghand);
        myNotifyBuilder.setContentIntent(myPendingIntent);
        myNotifyBuilder.build();

        Notification myNotification = myNotifyBuilder.getNotification();
        myNotifyMgr.notify(NOTIFICATION_ID, myNotification);
    }

    public void dismissNotification(View view) {
        myNotifyMgr.cancel(NOTIFICATION_ID);
    }

}

Listing 15-1Implementing the code for NotificationBuilderExample

虽然这里有相当数量的代码,并且在配套的NotificationFollowon类中,其中的大部分您应该已经很熟悉了。在onCreate()中设置活动执行恢复或创建状态和扩展布局的常规任务,另外还要创建绑定到系统通知基础设施的myNotifyMgr对象。NotificationBuilderExample类本身也为通知设置了一个虚构的 ID 和一个计数器来跟踪有多少未决通知。请注意,您可以很容易地决定让您的应用发出多种不同类型的通知。如果您决定这样做,请确保使用不同的 ID 来区分每种类型。

onCreate()中的另一个主要逻辑执行必要的 SDK (API)级别检查,以查看是否有必要使用通知通道来向用户显示所需的通知。如果 SDK 版本处于或高于通道被授权的级别,我们检查MYCHANNEL是否存在(null比较),如果它还不存在,我们实例化它以备使用。如果它确实存在,就不需要额外的工作——例如,如果我们已经使用了该应用,并在触发通知至少一次后让它继续运行。

onClick()方法是我用来将按钮点击处理分组在一起的熟悉模式——尽管在这个例子中,你可以很容易地让每个按钮直接调用相关的raiseNotification()dismissNotification()方法。正是这些方法的实现包含了我们感兴趣的通知逻辑。

raiseNotification()方法中,我们执行本章开始时描述的几乎所有可选配置和定制。首先,我们创建一个指向NotificationFollowon活动的PendingIntent。如果用户决定点击通知抽屉中的挥动的手图标,这将被触发。

接下来,创建Notification(或者在本例中是NotificationCompat ) Builder对象,我们将从这个构建器实例生成的任何结果通知分配给在onCreate()方法中设置的MYCHANNEL通道。

然后我们开始使用myNotifyBuilder对象,为我们将构建的最终Notification对象添加许多通知功能:

  1. 。调用 setTicker()来提供一些 Ticker 文本。

  2. 。调用 setNumber()来增加引发通知的次数。

  3. 。调用 setSound()并从 raw 文件夹中获取 pop.mp3 声音作为资源。

  4. 。调用 setVibrate()时,节奏为振动开启 1 秒,关闭半秒。

  5. 。调用 setAutoCancel()来禁用自动取消选项。

配置好所有选项后,我最终将构建的当前状态传递给myNotification对象,然后将NotificationNOTIFICATION_ID传递给NotificationManager以呈现给用户。

从用户的角度查看通知

在虚拟设备上运行NotificationBuilderExample应用提供了大部分通知体验(振动往往是 avd 处理不好的一件事)。图 15-2 显示了出现在主屏幕图标栏中的通知。

img/499629_1_En_15_Fig2_HTML.jpg

图 15-2

左上角触发的通知——挥动的小手图标

如果你仔细看,你会在屏幕顶部看到一个小的挥手图标。如果在打印(或屏幕)页面上很难看到,请确保自己尝试运行该示例,以在自己的虚拟设备上看到它。根据 AVD 支持的 API 级别,您可能会看到也可能看不到与通知对象相关联的附加状态文本。

单击 Clear Notification 按钮会使图标消失,如果您在 pop 声音仍在播放或真实设备仍在振动时足够快地单击它,这些额外的自定义也会停止。

通知贯穿于NotificationBuilderExample活动的整个生命周期,甚至在您将它发送到后台以便使用其他应用或返回到启动程序主屏幕之后。自己尝试一下,你应该还能看到如图 15-3 所示的通知图标。

img/499629_1_En_15_Fig3_HTML.jpg

图 15-3

通知图标即使在离开活动后仍然存在

一旦发出通知,在此后的任何时间点,用户都可以访问通知抽屉,该抽屉从设备上的所有应用收集所有通知(如果使用足够新的 API 级别和兼容的设备,还可能通过通道对它们进行分组)。在 Android 中,你可以通过“抓住”屏幕顶部的栏并一直拖到底部来打开通知抽屉。在图 15-4 中,您可以看到我添加了附加细节的示例通知,包括通知标题和附加文本。

img/499629_1_En_15_Fig4_HTML.jpg

图 15-4

通知抽屉打开,显示我们的通知

您在通知抽屉中看到的内容直接取决于正在使用的 Android 的版本。上图中的示例来自 Android 10.0 AVD 实例,我们的图标是从适当的屏幕密度资源中选择的,或者是从与应用打包在一起的最近的可用资源中缩放的。我们 25×25 像素的挥动的手在通知抽屉中更容易辨认。显示了标题和附加文本,以及在创建时传递给Notification对象的时间戳。

你还会注意到在较新的 Android 版本的通知抽屉中显示的数字值,而不是图标栏中图标的覆盖图。谷歌对 Android 进行了这一改变,以处理开始蔓延到较小手机屏幕上的图标显示的混乱。通过将号码转移到通知抽屉,人们的启动屏幕变得不那么拥挤了。

用户可以单击图标来触发后续活动,或者简单地取消通知,就像他们在活动主页上单击清除通知按钮一样。在 Android 的最新版本中,你会在通知下方看到三个稍微偏移的水平条,它们是“全部忽略”选项。甚至新版本的 Android 也会在通知抽屉底部显示“全部清除”和“管理”选项。在我们的示例中,无论您选择哪种清除技术,都将在所有活动的 NotificationManager 对象上触发cancelAll()方法,从而完全清除流程中的通知抽屉。

一个完全清晰的通知抽屉看起来如图 15-5 所示。

img/499629_1_En_15_Fig5_HTML.jpg

图 15-5

从设备中清除的所有通知

请注意,以这种方式清除通知不一定会清除您可能在应用中跟踪的通知计数。请记住,即使你已经脱离了应用,Android 也不一定会触发onDestroy()或从应用中获取资源。

摘要

你现在已经有了 Android 通知系统的坚实基础和使用通知的经验。还有更多扩展通知及其用途的高级主题,您可以在本书的网站 www.beginningandroid.org 上找到。这些高级通知主题包括时间线通知、相关通知组或集合的捆绑通知、可扩展通知以及针对 Wear OS 和 Android 嵌入式应用(如 Android Auto)的专用通知类型。

十六、通过呼叫探索设备功能

我们已经在本书中介绍了所有的想法,并且在后面的章节中还会介绍,因此很容易一头扎进各种只处理 Android 提供的强大软件平台的应用开发中。事实上,这太容易了,以至于你经常忽略了作为一个 Android 开发者的另一个巨大的机会:也使用 Android 硬件。

在本章中,我们将简要介绍如何开始使用设备功能,特别是通话和传感器。这将是非常简短的,但应该给你一个开始,继续学习更多关于硬件能力的独立知识。

发号施令

Android 已经从早期的全手机时代走了很长一段路。正如我在第一章中概述的那样,随着现在使用 Android 的设备和外形的爆炸式增长,提前思考您的应用如何以及为什么可能想要添加电话支持,以及它如何适应“没有电话”的环境和设备是值得的

指定电话支持

要向 Android 标记您的应用需要访问与电话相关的硬件特性,您应该将以下硬件要求条目添加到您的AndroidManifest.xml文件中:

<uses-feature android:name="android.hardware.telephony" android:required="true" />

android.hardware.telephony 功能表明您的应用需要蜂窝接入和支持,并意味着用户可以提前了解该应用是否适合他们的设备,例如在 Google Play 上搜索或下载应用时。

使电话支持成为可选的

如果你觉得把整个应用挂在是否允许蜂窝接入上有点极端,那你就对了。如果您正在考虑构建一个应用,其中电话接入是额外的好处,但不是硬性要求,那么您可以利用技术在应用运行时检查应用逻辑中的电话支持,并处理拥有和不拥有蜂窝硬件接入的情况。

Android 使用PackageManager类来帮助检测各种硬件,从加速度计和麦克风一直到手机硬件的功能。最常见的方法是使用hasSystemFeature()方法,如清单 16-1 中的伪代码片段所示。

PackageManager myDevice = getPackageManager();
if (myDevice.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) {
    // the user's device has telephony support
    // add your call-related logic here
} else {
    // the user's device lacks telephony support
    // do something that doesn't require making calls
};

Listing 16-1A code fragment showing detection of cellular hardware access

您还可以检查电话支持的其他有用方面,如网络类型和语音实现,例如 VoLTE、LTE、GSM、CDMA 等。

打电话

现在您已经有了确定设备调用支持的需求或可取性的机制,您可以开始在您的应用中利用这一点。令人欣慰的是,Android 让这一点变得非常简单,这要归功于其作为智能手机操作系统的根基——重点是在手机上。

Android 让通话和电话的其他方面变得容易访问和使用的非常有用的方法集中在TelephonyManager类上。顾名思义,TelephonyManager 负责一系列呼叫管理和相关任务,包括呼叫处理、呼叫状态、网络细节等。您通常会发现自己在使用这些方法:

  1. getPhoneType():返回电话和网络的详细信息,包括对 GSM、LTE 等的无线电支持。

  2. getNetworkType():该方法提供了当前连接的蜂窝网络的数据能力的详细信息。这有助于理解网络类别,如 LTE、4G、3G 和其他变体。

  3. getCallState():这是一种非常方便的方法,可以帮助您确定手机是否空闲(未在通话中)、处于通话设置模式还是正在通话中——即所谓的“摘机”或“摘机”

要真正拨打一个号码并开始通话,您可以调用ACTION_DIALACTION_CALL意图之一。这些方法的使用和区别将很快被介绍。这两种方法都有一个共同的出发点,那就是将代表用户希望呼叫的号码的Uri作为格式为tel: nnnnnnnn的字符串。在 Uri 字符串中,nnnnnnnn代表要呼叫的电话号码的数字。观看实际操作过程将有助于清楚地了解拨打电话的步骤。

Caution

在发起任何新的呼叫之前,检查当前的呼叫状态是一个很好的做法。Android 有一系列选项可以同时处理多个来电和去电,但这个话题完全属于高级蜂窝争论的范畴,超出了本书的范围。现在,好好利用getCallState()方法,把你的行动建立在它带来的结果上。

布局 CallExample 应用

打电话应用的工作示例可以在Ch16/CallExample项目中找到。这个项目使用一个非常简单的布局,让您可以专注于它所公开的呼叫和拨号选项。首先,您可以看到清单 16-2 中使用的布局。

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

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Phone Number:" />

    <EditText
        android:id="@+id/phonenumber"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="number" />

    <Button
        android:id="@+id/usedialintent"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Call with ACTION_DIAL"
        android:onClick="callWithActionDialIntent" />

    <Button
        android:id="@+id/usecallintent"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Call with ACTION_CALL"
        android:onClick="callWithActionCallIntent" />
</LinearLayout>

Listing 16-2The layout of the CallExample application

查看布局中指定的字段,您会看到一个TextViewEditText的组合,它充当用户指定他们希望呼叫的号码的输入字段。然后有两个按钮,"Call with ACTION DIAL""Call with ACTION_CALL,你可以猜到,这两个按钮触发各自的方法来激发每种类型的意图,从而打电话。

你可以在图 16-1 中看到最终的渲染布局。

img/499629_1_En_16_Fig1_HTML.jpg

图 16-1

用于拨打用户指定号码的 CallExample 布局

除了展示呼叫功能所需的部分之外,我特意选择了一些新功能包含在此布局中——让我们先把这些功能去掉。为了有一点变化,我将这个布局基于一个LinearLayout。您将看到 id 为phonenumberEditText视图具有属性inputType="number"。这将触发 Android 修改该视图的输入法参数,以便只为数字和一些有限的标点符号提供输入。您可以在运行应用时看到这种效果,因为为输入显示的虚拟键盘(或输入法编辑器(IME))看起来像电话拨号盘,而不是完整的键盘。

The Android Input Method Framework

Android 提供了一个非常强大的抽象层来处理用户输入,以便它可以灵活地与物理键盘、屏幕上出现的软键盘甚至手写识别硬件和软件一起工作。这是输入法框架。

每当您使用一个触发用户输入的视图时,您通过被呈现一个默认的编辑器视图——IME——来隐式地使用框架。您可以使用默认设置,并像我们在电话拨号示例中所做的那样进行配置,也可以用于其他常见情况,如日期输入。您还可以自定义任何 IME 来添加或限制编辑器中显示的供用户“按”的“键”或值。你可以在这本书的网站上,在 www.beginningandroid.org 阅读更多关于输入法框架和 ime 的内容。

usedialintentusecallintent按钮具有这些 id,当每个按钮被点击时,它们会给出一个强烈的提示。使用第一个按钮"Call with ACTION_DIAL"将遵循触发ACTION_DIAL意图的代码路径,并且"Call with ACTION_CALL"将类似地触发ACTION_CALL意图。我们将在第十七章更详细地讨论意图,但是现在,这两者之间有什么区别呢?

带着一个ACTION_DIAL意图,Android 被通知它需要向用户显示一个 IME,以在幕后启动电话魔法之前确认(或调整)要呼叫的号码。在本章后面的图 16-2 中可以看到这一点。在另一种情况下,触发ACTION_CALL意图会立即使用 Uri 中提供的号码发起呼叫,而无需任何进一步的用户界面或确认。

img/499629_1_En_16_Fig2_HTML.jpg

图 16-2

调用示例应用触发 ACTION_DIAL

这两种不同的方法有许多原因,但主要原因是确保用户知道呼叫即将开始,并通过ACTION_DIAL为他们提供对过程的控制。当你考虑到这是 Android 的内置部分之一,可能会花费用户真正的金钱时,这是非常重要的。在某些地方打电话很便宜,但在许多国家和地区,打电话仍然是一笔不小的开销。

因为ACTION_CALL在没有ACTION_DIAL提供的确认步骤的情况下立即进行呼叫,Android 为ACTION_CALL提供了一种保护措施,要求在应用中使用它必须在其清单中有CALL_PHONE的许可,然后带有ACTION_CALL意图的startActivity()呼叫才会起作用。还要注意,CALL_PHONE 权限被认为是最高级别的权限,因为有可能被滥用,因此,不仅您必须在应用清单中拥有此权限,而且在运行时,您的用户还会被提示允许应用进行呼叫。可以说是纵深防御。

CallExample 应用的工作逻辑

了解了通话选项并准备好向用户展示简单的布局后,是时候看看让通话变得生动的逻辑了。清单 16-3 展示了为我们的CallExample应用插入逻辑的 Java 代码。

package org.beginningandroid.callexample;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.EditText;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    public void callWithActionDialIntent(View view) {
        EditText targetNumber=(EditText)findViewById(R.id.phonenumber);
        String dialThisNumber="tel:"+targetNumber.getText().toString();
        startActivity(new Intent(Intent.ACTION_DIAL, Uri.parse(dialThisNumber)));
    }

    public void callWithActionCallIntent(View view) {
        EditText targetNumber=(EditText)findViewById(R.id.phonenumber);
        String callThisNumber="tel:"+targetNumber.getText().toString();
        //the following intent only works with CALL_PHONE permission in place
        startActivity(new Intent(Intent.ACTION_CALL, Uri.parse(callThisNumber)));
    }

}

Listing 16-3Java logic for the CallExample application

我通常使用一个onClick()方法来指导随后的执行,在这个例子中,我们使用一个 switch 语句作为参数,在这个例子中,我们分别从每个按钮的布局配置中直接调用方法callWithActionDialIntent()callWithActionCallIntent()

每种方法都做一些类似的处理,首先确定用户在哪个View(在我们的例子中是EditText)中输入了想要的电话号码。然后用适当的格式创建Uri字符串,然后调用startActivity(),用期望的意图和Uri作为参数。

拨打操作拨号电话

在图 16-2 中,可以看到用户点击useDialIntent按钮的结果。来自EditText字段的数字(如果有的话)已经在我们的callWithActionDialIntent()中被构造成一个幕后的Uri,并且ACTION_DIAL意图已经被触发。

你在图 16-2 中看到的拨号器看起来与图 16-1 中显示的 IME 很接近,但不完全一样。您应该注意到的主要区别不仅包括号码样式、配色方案等的细微差别,还包括添加选项,例如将该号码添加为联系人的能力。你可能已经猜到这是通过激发另一个意图来实现的。

您还会看到数字和任何相关标点符号(如连字符和括号)的格式差异。这些都将根据设备的位置和语言设置进行样式化。用于图 16-2 所示视图的我的 AVD 使用美国地区和英语作为语言,因此你看到的格式的前三个数字被视为区号并放在括号中,数字的连字符是美国和加拿大惯用的。

最后,也是最重要的,是显示屏底部的绿色电话软键,你可以猜测它实际上是用来触发呼叫的。

拨打行动电话

只要按下【开始】按钮,你就能看到——嗯,它也能看到 Android 的拨号屏幕,其他什么都没有。上例中的拨号器未被触发,因此几乎没有其他内容可以显示或解释。你的用户会直接进入“实际打电话”拨号画面,如图 16-3 所示。

img/499629_1_En_16_Fig3_HTML.jpg

图 16-3

使用 ACTION_CALL 在行动中呼叫

处理来电

处理来电比接听电话要复杂得多,超出了本书的范围。但是,并不总是需要对来电承担全部责任,当接到电话时,您可以让您的应用做其他有用的事情,即使您的应用不是处理管理对话的主要任务的应用。

让辅助应用响应传入呼叫的主要方法是在 AndroidManifest.xml 文件中为广播目的注册一个广播接收器。我们将在下一章更深入地探讨广播接收机。现在,知道ACTION_PHONE_STATE_CHANGED意图是由TelephonyManager框架在收到调用时触发的就足够了。清单 16-4 展示了清单文件中的接收者声明。

<receiver android:name="MyPhoneStateChangedReceiver">
    <intent-filter>
        <action
            android:name="android.intent.action.PHONE_STATE"  />
    </intent-filter>
</receiver>

Listing 16-4Setting the receiver for incoming calls in AndroidManifest.xml

当对设备进行调用时,TelephonyManager触发 intent,任何接收者——包括您的接收者——通过使用对指定的相应方法的回调得到通知。ACTION_PHONE_STATE_CHANGED intent 还可以包括两个可选的数据片段,您可以使用它们来驱动您的逻辑。一个是呼叫的状态值,如CALL_STATE_OFFHOOKCALL_STATE_RINGING,表示呼叫已被应答或仍在触发等待应答的振铃器。如果使用了CALL_STATE_RINGING值,还有一个可选的附加值EXTRA_INCOMING_NUMBER,它提供主叫方 ID(如果网络已经提供的话)。

清单 16-5 是MyPhoneStateChangedReceiver类的一个示例 Java 方法,它可以让你知道什么时候可以进行回调。

public class MyPhoneStateChangedReceiver extends BroadcastReceiver {
    @override
    public void onReceive(Context context, Intent intent) {
        String deviceCallState = intent.getStringExtra(TelephonyManager.EXTRA_STATE);
        if (deviceCallState.equals(TelephonyManager.EXTRA_STATE_RINGING) {
            // The phone is still ringing and might have the caller ID
            String callerID =
              intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER);
            // Try to display the number, etc.
        } else {
            // do something else
        }
    }
}

Listing 16-5A Java method fragment for working with an incoming call

与其他硬件功能一样,处理来电被视为敏感任务,需要明确的安全权限。您的应用将需要清单文件中的READ_PHONE_STATE权限,以便接收ACTION_PHONE_STATE_CHANGED意图。

十七、理解意图、事件和接收者

当你深入到本书的后半部分时,你已经接触到了许多设计和构建活动的技术。您已经牢牢掌握了活动生命周期,以及在活动开始、等待、暂停和最终结束的各个时间点触发的回调。到目前为止,大多数示例应用都只包含一个活动。这与我在本书开始时所做的评论——创建、使用和处理活动的成本很低,并且您应该多产地使用它们——有什么关系呢?

很高兴你问了!我们希望确保您可以轻松使用和部署多项活动。虽然早期的视频播放器、电话拨号器等示例都是很好的例子,但是这些示例应用都是在应用启动时启动其单个活动的,方法是在 AndroidManifest.xml 文件中指定 activity,并在名为的子元素中指定几个有趣的属性。这提供了一点线索,说明一个以上的活动可能会发生什么,但是我们在哪里以及如何指定第二个和后续的活动,以及如果不是在应用启动时,我们如何在需要时启动它们?现在让我们来解开这些谜团。

介绍 Android 意图

关于在应用中使用多个活动的谜题的答案在于 Android 的基于事件或基于消息的系统,即所谓的“意图”。正如许多其他操作系统一样,如 Linux、Windows 和 macOS,它们基于发送和响应事件,Android 使用许多类似的概念,其意图包括触发和响应需要向应用呈现不同活动的操作。这在 Android 中有一些细微的差别,所以请继续阅读!

最简单的形式是,意图是从 Android 上的应用或服务发送的消息,表明该应用或服务的用户想要做些什么。那个“某物”可能是一个非常众所周知的动作,比如根据用户的动作准确地知道向用户显示哪个活动。但是 Android 也提供了从基础平台和其他应用中使用其他活动的可能性,从你自己的应用中。在这种情况下,如果有多种方式来满足用户的意图,您可能无法控制其他可用的活动,也无法控制设备用户可能更喜欢哪些活动。Android 通过使用消息系统的匹配部分(称为接收者)涵盖了已知和未知选项。

接收者的工作——不管是你写的还是其他应用的——是倾听意图,如果接收者有能力处理这种被请求的意图,就以各种方式做出回应。在本章的后面,在我们介绍了意图的机制之后,我们会谈到接受者。意图和接受者共同构成了触发连续活动的中心机制,并将应用中想要的所有活动连接在一起,以创建最终的体验。另外,这种相同的机制允许您利用其他应用的活动,只要这对您的应用有意义。

理解意图行为

剖析 Android 意图的两个基本部分是用户或应用所期望的动作和触发该期望动作的上下文。当我们谈论期望的行动时,我们指的是一些简单的概念,如“查看这个东西”、“制作一个新的”,等等。我们将很快涵盖更详尽的行动清单。就上下文而言,它可以更多样,最好被认为是一系列有助于理解意图的支持数据,以及任何细微差别或特殊情况,因此它可以得到适当的指导,并为最终的活动服务。

这种支持数据的概念采用了Uri的形式,比如content://contacts/people/4,这是 Android 联系人存储中第四组联系人详细信息的Uri符号。如果你把那个Uri和一个类似ACTION_VIEW的动作搭配起来,你就拥有了意图的所有基本要素。Android 解释了这一意图,并将找到一个能够向用户显示(或向视图提供)一组联系信息的活动。相反,如果你在指向一个集合的Uri上做类似于ACTION_PICK的动作——content://contacts/people——Android 将寻找任何可以呈现多个联系人的活动,并提供从中进行选择的能力。

虽然与动作捆绑在一起的Uri是意图的最简单形式,但这并不是可以包含的内容的限制。你可以在你形成的任何意图中包括四个额外的方面,以扩展意图有效载荷,并帮助改善 Android 和应用可以利用意图及其数据有效载荷做什么。这些都是意图对象的一部分:

  • 意图类别:意图的类别有助于定义什么活动可以满足其基本动作。举个例子,你希望你的用户开始与你的应用交互的“主要”活动将属于类别LAUNCHER。通过这种方式,你向 Android 发出信号,该活动适合包含在启动器菜单(你的 Android 主屏幕)中。其他活动/意图类别包括DEFAULTALTERNATIVE

  • MIME 类型规范:有时不可能知道或定义一个特定的 Uri 用于一组项目,比如联系人或照片。为了帮助 Android 找到一个合适的活动来处理这些情况下的数据集,您可以指定一个 MIME 类型,例如,image-jpeg 用于图像文件,以帮助处理照片集。

  • 组件提名:Android 的优势之一是能够在运行时利用您在构建应用时不知道的活动。但是在其他时候,您确切地知道您想要调用哪个活动。一种方法是在意图的(期望的)组件中指定活动的类。通过这种方式,您不需要添加其他上下文提示来希望触发正确的意图,代价是承担关于组件类实现的假定知识的风险。这违背了被称为封装的面向对象编程的原则。

  • 额外内容:有时你有其他上下文线索和数据,出于各种原因,你想让接收者知道。当这不完全符合一个命名方案时,临时演员会来帮忙。这是一个简单的 Bundle 对象,可以包含您想要的任何内容。一个警告是,提供这样一个包并不能保证接收者会使用它。

适合各种场合的有意行动

你可以在 https://developer.android.com/reference/android/content/Intent#constants_1 找到 Android 文档提供的意图动作和类别的完整列表,所以我不会在这里重复。相反,让我们看看最常见和最有趣的动作,为本章和本书其余部分即将出现的例子提供信息:

  1. ACTION_AIRPLANE_MODE_CHANGED:设备的用户已经将飞行模式设置从开切换到关,反之亦然。

  2. ACTION_CAMERA_BUTTON:点击了相机按钮(硬按钮或软按钮)。

  3. ACTION_DATE_CHANGED:日期已经更改,这意味着您编写的任何使用计时器、运行时间等的应用逻辑都可能受到影响。

  4. ACTION_HEADSET_PLUG:设备的用户已经将耳机连接到耳机插座或从耳机插座移除耳机。

如您所见,意图动作涵盖了应用和整个设备环境中发生的各种事情。

注意:Google 会在 Android 的每个新版本中添加新的动作。它还反对(后来删除)一些它认为不再有用的旧操作。随着时间的推移,在维护应用时,您应该检查应用所依赖的操作是否已被否决或移除。

了解意图路由

您可能认为,本章前面提到的组件命名通常是调用您想要的任何和所有活动的好方法,无论是您自己编写的活动还是其他活动。现实有点不一样。

如果你有一个自己编写的活动类,并且理解它是如何以及为什么是意图的最佳接受者,那么这样做是完全可以的。但是当处理来自其他应用的活动时,它是不可靠的,有时是不安全的,并且会导致不良的、意想不到的或者完全不正确的行为。

封装的编程原则是这个警告的核心。考虑一下让其他开发人员依赖您自己的类和它们的内部实现。作为一名成熟的开发人员,您将不断地调整代码和类,这就引入了一种非常真实的可能性,即有人对类内部的假设最终会是错误的,因为您已经改变了逻辑。反过来也是一样,你不应该依赖于实现逻辑在别人的应用中保持不变(甚至根本不存在)。

为了使用意图模型安全地定位您选择的应用或服务,最好使用 uri 和 MIME 类型。如果我们可以窥视其他人的应用的内部,那么我们如何将 Android 定向到偏好的活动或接收者(如服务)来接收意图呢?答案在于 Android 的隐式路由方案,它根据一组资格规则将意图传递给所有应该接收它们的活动等等。

隐式路由方案的规则如下:

  1. 活动必须通过适当的清单文件条目(将在下一节中讨论)来表明其处理意图的能力。

  2. 如果 MIME 类型是意图上下文的一部分,则活动必须支持该 MIME 类型。

  3. 活动还必须支持事件上下文中的每个类别。

这些规则有助于缩小可以接收您的意图的匹配活动的可能集合,只包括那些以适合您使用的方式忠实地执行意图的活动。

在你的清单中包含意图

Android 使用您的AndroidManifest.xml文件作为保存意图过滤器的位置,指示您的应用中的哪些组件可以得到通知并对给定的意图做出适当的响应。如果您的组件的清单条目没有列出意图动作,那么它不会被选择通过隐式路由机制接收这种意图的通知。您可以将此视为组件处理意图的选择加入方法,构建您希望使用的意图列表。

当您创建一个新的 Android 项目时,Android Studio 会创建您的第一个意图过滤器,作为骨架AndroidManifest.xml文件的一部分。到目前为止,您已经在本书中的所有示例应用中看到了这一点。作为复习,清单 17-1 是第二章中第一个应用MyFirstApp的清单。以粗体显示的是活动MainActivity及其指定的意图文件。

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

    <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>
    </application>

</manifest>

Listing 17-1Example intent filters from an AndroidManifest.xml file

意图过滤器的两个部分是其操作的关键。首先,我们指定MainActivity活动属于类别LAUNCHER,这意味着触发它的任何意图也必须属于该类别。其次,我们还指定了动作android.intent.action.MAIN,它用于指定任何寻找具有MAIN能力的活动的意图都可以被接受。您可以为您的MainActivity提供更多可能的动作以及更多的类别,这表明您通过为活动的类编写的任何逻辑支持它的更多功能。

您的应用的任何其他活动都不会使用动作和类别的MAIN/LAUNCHER组合——在几乎所有情况下,您在清单中只使用一个这样的指定。虽然有许多类别和动作可供选择,但您最终会非常频繁地使用DEFAULT类别,尤其是对于任何查看或编辑风格的动作,以及描述活动可以用来查看或编辑的mimeType<data>元素。例如,清单 17-2 显示了一个 notes 风格应用的意图过滤器。

<activity
    android:name=".MyNotesActivity"
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <data android:mimeType="vnd.android.cursor.dir/vnd.google.note" />
    </intent-filter>
</activity>

Listing 17-2An intent filter for an example secondary activity in your application

前面定义的活动包括一个意图过滤器,它描述了如何启动该过滤器来处理来自任何应用的意图,该应用使用带有vnd.android.cursor.dir/vnd.google.note mimeType的内容的 Uri 来请求查看内容。这个意图可能来自您自己的应用,比如跟随您的启动器活动中的一个用户操作,或者来自任何其他可以为此活动创建一个格式良好的Uri并将其用作它所触发的意图的有效负载的应用。

使用 Android 的验证链接

处理意向 uri 的另一个特性是自动验证链接的机制,它专门处理也是有效 URL(即网站地址)的 uri。通过验证链接,您可以在代码中添加一个明确的规则,使来自特定网站的 URL 和配套应用之间的链接 100%得到验证,并成为处理具有匹配 URL 的意图的首选方式。这包括后台机制,以在需要时实现无缝认证,并避免 Android 历史上使用的传统“网站或应用”选择器对话框。

看到行动中的意向启动活动

有了意图的理论和结构的知识,你就可以开始研究一个例子来说明它们的力量和便利。我已经多次提到了 Android 的理念,它有许多活动来支持您的应用所需的一系列功能,例如相册应用有一个给定的活动来查看单个图片,另一个活动来查看组或相册(可能使用GridView),甚至更多的活动来标记、在社交媒体上共享等等。

在我们深入研究如何实现多活动应用之前,还有最后一点需要考虑。如果您的应用通过一个意图从一个给定的活动启动一个活动,那么调用活动应该关注关于启动的活动的状态的什么信息?您的启动活动是否需要知道第二个(或后续)活动的任何信息,比如它何时完成或完成了什么工作,并被传递一些结果?

决定活动依赖性

为了处理依赖与不依赖的问题,Android 提供了两种主要的方法来调用有意图的活动。

第一种方法是StartActivity()方法,用于标记 Android 应该找到与意图负载最匹配的活动——包括动作、类别和 MIME 类型。“获胜”活动开始,来自 intent 的有效负载将被传递,以供活动使用。调用活动将继续其生命周期,并且不会收到任何关于被调用活动的工作、生命周期事件、数据变更等的更新或通知。

依靠抽签或类似的方式,听起来可能不是影响应该选择哪种活动来处理意图的最佳方式。你不必担心,因为还有第二种方法叫做startActivityForResult()方法。在startActivityForResult()中,不仅仅是一个结构良好的意图被用来影响哪个活动被触发;它还包括对所需活动的特定引用和对活动调用的唯一调用号。当被调用的活动结束时,通知被发送回调用活动,这意味着您可以模拟传统的用户体验,即父窗口或屏幕打开子窗口或屏幕,处理登录请求或从可用列表中选择选项。具体来说,回调包括

  1. 与特定于该调用的活动和原始startActivityForResult()方法相关联的唯一调用号。在这种设计中,您可以选择使用一种切换模式来确定哪些子活动已经完成,并继续适当地执行您的应用逻辑。

  2. 一个来自 Android 提供的结果RESULT_OKRESULT_CANCELED的数字结果代码,以及您想要以RESULT_FIRST_USER, RESULT_FIRST_USER + 1的形式提供的任何自定义数据或结果,等等。

  3. (可选)一个包含被调用活动应该返回的任何数据的String对象,比如从一个ListAdapter中选择的项目。

  4. (可选)一个Bundle,包含不完全符合前三个选项的任何附加信息。

有了在startActivity()startActivityForResult()之间的选择,您主要关心的应该是在应用设计时确定哪一个是最好的。在运行时动态地确定这一点是可能的,但是在潜在的几十或几百个活动中处理所有新出现的可能性可能会变得令人不知所措。

创建意图

了解了如何使用您想要的方法调用活动之后,剩下的工作就是创建Intent对象,用作触发其启动的有效负载。如果您想在自己的应用领域内启动另一个活动,那么最直接的方法是直接创建您的意图,并明确地声明您希望激活的组件。这将使您创建一个新的意图对象,如下所示:

new Intent(this, SomeOtherActivity.class);

这里,您显式地引用您想要调用您的SomeOtherActivity活动。对于这种风格的直接调用,您不需要在您的AndroidManifest.xml文件中构建意图过滤器——无论您的SomeOtherActivity喜欢与否,它都会启动!显然,作为开发人员,你有责任确保你的SomeOtherActivity能够做出相应的响应。

我在本章的前几节中概述了使用一个Uri和匹配的标准集来馈送给 Android 的优雅和偏好,以便它可以为您的使用找到合适的活动。Android 有一系列受支持的Uri方案,你可以自由创建一个与其中任何一个相匹配的Uri。作为一个例子,下面是为联系人系统中的一个联系人创建一个Uri的代码片段:

Int myContactNumber = 4;
Uri myUri = Uri.parse("content://contacts/people/"+myContactNumber.toString());
Intent myIntent = new Intent(Intent.ACTION_VIEW, myUri);

我们使用第四个联系人的号码,然后构造Uri字符串来引用该联系人。然后我们可以将这个Uri传递给 Intent 对象。

启动意图调用的活动

你已经建立了你的意图,所以是时候选择打电话给startActivity()startActivityForResult()中的哪一个了。您可以考虑一些更高级的选项,但它们超出了本书的范围。如果你感兴趣,Android 文档对包括startActivities()startActivityFromFragment()startActivityIfNeeded()在内的选项有更多的说明。

清单 17-3 展示了一个来自ch17/IntentExample项目的样本布局,使用了一个非常简单的布局,只有一个标签、一个字段和一个按钮。

<androidx.constraintlayout.widget.ConstraintLayout
    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">

    <TextView
        android:id="@+id/textView1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignBaseline="@+id/myContact"
        android:layout_alignLeft="@+id/button1"
        android:layout_alignBottom="@+id/myContact"
        android:text="Contact Number:"
        tools:layout_editor_absoluteX="41dp"
        tools:layout_editor_absoluteY="31dp" />

    <EditText

        android:id="@+id/myContact"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:layout_toRightOf="@+id/textView1"
        android:ems="10"
        android:inputType="number"
        app:layout_constraintStart_toEndOf="@+id/textView1"
        tools:layout_editor_absoluteY="19dp">

        <requestFocus />
    </EditText>

    <Button
        android:id="@+id/button1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@+id/myContact"
        android:onClick="viewContact"
        android:text="View Contact"
        app:layout_constraintTop_toBottomOf="@+id/myContact"
        tools:layout_editor_absoluteX="16dp" />

</androidx.constraintlayout.widget.ConstraintLayout>

Listing 17-3A layout to demonstrate intents

Note

在这个 ConstraintLayout 示例中,我故意让 EditText 和 TextView 在垂直和/或水平方向上不受完全约束。您将在 Android Studio 中看到警告,由于没有这些约束,小部件将“跳到”没有它们的默认位置。在这种设计中,这仍然会产生所需的布局,但是如果您愿意,可以在运行该示例时向这些视图添加约束。

如果您查看布局中基于视图的小部件,您会看到按钮将调用一个viewContact()方法。为了给用户带来他们期望的体验,我们需要像前面所解释的那样编写创建联系人Uri的代码,并使用它来创建Intent对象,该对象将启动一个活动来(希望)显示一个联系人。示例代码如清单 17-4 所示。

package org.beginningandroid.intentexample;

import androidx.appcompat.app.AppCompatActivity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.EditText;

public class MainActivity extends AppCompatActivity {
    private EditText myContact;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        myContact=(EditText)findViewById(R.id.myContact);
    }

    public void viewContact(View view) {
        String myContactNumber=myContact.getText().toString();
        Uri myUri = Uri.parse("content://contacts/people/"+myContactNumber);
        startActivity(new Intent(Intent.ACTION_VIEW, myUri));
    }

}

Listing 17-4Java logic for our intent-triggering contact application

这里使用的逻辑非常简单和直接。这可以让你专注于我们在本章中讨论的内容。

Note

要在实践中看到这个例子,您需要使用您的 AVD 或设备来创建一些联系人并保存 AVD 的状态,以便它们在重新启动 AVD 时不会丢失。这样意向火了就有联系人可以实际显示了!

运行IntentExample项目将导致IntentExample活动,如图 17-1 所示。

img/499629_1_En_17_Fig1_HTML.jpg

图 17-1

发布后显示的主要意图示例活动

您首先看到此活动的原因是因为 Android Studio(或您正在使用的任何其他 IDE)的自动项目设置已经在您的清单文件中添加了启动器类别的必要属性和数据值。

在编辑文本字段中输入一个联系人的号码,然后单击查看联系人按钮,意向人和接收人就会施展他们的魔法。您的联系人 Uri 被捆绑到您的意图中,对 startActivity()的调用将 Android 发送出去,从设备上的所有应用中筛选所有活动,以找到最合适的活动来处理您的意图的 ACTION_VIEW 操作。你可以在图 17-2 中看到结果。

img/499629_1_En_17_Fig2_HTML.jpg

图 17-2

为满足我们的意图而选择的联系活动

你在图 17-2 中看到的是当前 Android 原生联系人视图活动,而不是我在IntentExample项目中编码的任何内容。您会认为这是从您的应用外部安全地调用一个活动,因为我们采取了正确的步骤来提供一个Uri,并且我们可以安全地期望 Android 本身找到正确的活动来实现我们的意图。没有使用组件命名方法强行要求特定的活动。

介绍接收器

在本章中,我们探讨了如何使用意图在应用中创建和激活多个活动,目的是在应用中处理各种各样的动作,以响应用户的交互和需求。但是并不是对用户意愿的每个响应,或者触发的意图,都需要在(另一个)活动的范围内处理。有许多满足意图的真实世界的例子,其中你确实不需要一个活动的所有特性和复杂性,尽管它们是轻量级的。一些示例包括数据操作、执行计算等,其中计算或结果可以在不涉及任何 UI 或不想将意图指向 Android 服务而不是任何面向最终用户的应用的情况下确定。例如,您可能希望构建一个音乐共享服务,将所有音乐发送到云存储供应器进行备份,而无需任何用户交互。还有更高级的情况,直到运行时你才知道是否需要一个完整的 UI 活动,这意味着你需要同时为活动驱动和“无活动”的方法进行规划和设计。

不需要 UI 时使用接收器

为了处理这些“无活动”的情况,Android 提供了BroadcastReceiver接口和接收器的概念。您现在已经熟悉了基于活动的 UI 屏幕的轻量级特性,它可以快速处理用户交互,接收器是对它的补充,它提供了轻量级对象,创建这些对象是为了接收和处理广播意图,然后可以丢弃。

Android 文档显示在 BroadcastReceiver 的定义中只有一个方法,名为onReceive()onReceive()方法可以被认为是“开始吧!”方法。在接收器中实现任何所需的逻辑,并确保它以您所想的方式与相关的意图一起工作,这取决于您。

实现BroadcastReceivers以与活动相同的方式开始,在AndroidManifest.xml文件中有一个声明。使用元素名,带有实现BroadcastReceiver, f或示例的类的 android:name属性:

<receiver android:name=".ReceiverClass" />

您实现的任何接收器都是短暂的,只在执行您为实现其onReceive()方法而创建的逻辑的时间内存在,之后它将被丢弃以进行垃圾收集。接收者的行为是有限制的,有些是意料之中的,有些是意料之外的。正如您所期望的,当需要 UI 时,您不能在您的接收器逻辑中调用任何 UI 元素。不太令人期待但仍然重要的是,您不能发出任何回调的限制。对于在服务或活动(允许的组合)上实现的接收者来说,这些限制稍微放松了一些,在这些情况下,接收者在相关对象的生命周期内生存。

您不能通过服务或活动的清单来创建接收者。您可以使用onResume()中的registerReceiver()为这些情况动态生成接收者,以标记您的活动能够接收特定动作/类别/mime 类型组合的意图。如果您使用这种方法,您还必须在onPause()回调期间通过调用unregisterReceiver()来执行清理。

使用接收器限制

除了上一节提到的限制之外,您还应该了解接收器的一个额外限制。到目前为止所描述的意图和广播接收器的机制可能会让您认为可以将两者结合起来作为应用消息传递的通用方法。这个想法的“扳手”是活动生命周期。特别是,当活动暂停时,它们将不会收到意向。

暂停时无法接收意图意味着您会面临错过消息(意图广播)的问题,您需要避免接收者绑定到活动,而不是更喜欢通过AndroidManifest.xml.进行声明。您还需要考虑“重新发送消息”和重试逻辑,以便能够从任何错过的消息中恢复,或者考虑使用更复杂的第三方消息总线系统或类似方法。

摘要

在这一章中,我介绍了将应用从单活动程序扩展到多活动领域所需的所有机制!您在 Android 的意图和广播接收器机制方面有很好的基础,应该能够在您计划编写的应用中扩展活动的使用。

十八、Android 服务简介

由于其 Linux 操作系统的传统,Android 拥有许多平台特性,将 Linux 的强大功能带给了一个全新的用户群。这一丰富遗产的一部分包括一组在后台运行的特殊应用,没有用户界面,它们为运行在 Android 上的所有其他程序提供功能。这些在 Android 中被称为服务,类似于 Linux 中的守护进程概念和 Microsoft Windows 中的服务概念。

本章将探索 Android 服务的一些基础知识,向您展示创建、启动和操作服务的步骤。为了增加这种探索,我们还将查看一系列简单的服务示例,以拓宽您对使用服务的理解。

服务背景

服务的基本原理来自许多需求,特别是当功能或任务需要由一个或几个应用来执行,但这些任务不需要任何形式的 UI 交互或面向用户的活动来显示时。在任何 Android 设备上,有数百个服务在任何时间点运行,包括

  1. 提供本地接口来控制远程 API,如位置服务或地图应用。

  2. 为持续几天、几周、几个月或更长时间进行“对话”的 messenger 和聊天应用保持长期连接。

  3. 继续处理用户调用的任务或工作,无需进一步的交互。一个很好的例子是从 Google Play 商店和其他地方下载 Android 应用的更新。

这些只是你现在可以在 Android 设备上找到的一些例子,显然还有更多。例如,回想一下我们的音频和视频示例以及媒体框架,其中实际的音频和视频回放任务依赖于后台服务。

在构建应用时,你没有义务使用服务,但你可以将它们视为袖手旁观随时准备在需要时帮助你的帮手——包括你可能编写的服务和 Android 平台提供的默认服务。

使用工作管理器作为服务的替代

虽然这一章的其余部分集中于针对前一节中概述的需求的经过试验和测试的服务方法,但 Android 确实提供了其他方法。其中之一是 WorkManager,它是本书前面介绍的 Jetpack 库的一部分。WorkManager 允许您在后台执行工作,即使是在应用终止之后。您可以将 WorkManager 视为在应用之外异步执行工作的一种简单的手持方式。

WorkManager 有它的长处,你可以在 https://developer.android.com/topic/libraries/architecture/workmanager/basics 了解更多。服务的通用功能,以及它们的威力和效用,是一个引人注目的主题,需要掌握并添加到您的开发人员工具集中,我们将在本章的剩余部分对此进行深入研究。

从你自己的服务开始

定义和创建您自己的服务应用与您在制作普通的基于活动的 Android 应用时所学的非常相似。您应该对创建 Android 服务的步骤很熟悉:

  1. 使用 Android 提供的基类,扩展它并添加任何必要的类继承来创建您自己的定制服务。

  2. 确定需要覆盖哪些回调方法,然后编写代码来实现所需的逻辑。

  3. 添加必要的AndroidManifest.xml条目来提供权限、定义和到更广泛的 Android 平台的链接,以便您的服务可以运行并服务于其他应用。

在下一节中,我们将探索这些领域,让您有一个全面的了解。

实现您的服务类

在默认的 Android 开发框架中,Service类是作为构建自己的服务的基础而提供的。Service类还提供了几个现成的有用的子类,这些子类与许多开发人员拥有的通用服务模式相匹配,尽管您可以根据自己的需要从Service类本身或任何助手子类开始。其中,迄今为止最有用和最常用的是IntentService子类。

清单 18-1 显示了一个简单服务的基本代码大纲。

package com.artifexdigital.android.serviceskeleton

import android.app.service
//more imports here

public class SkeletonService extends Service {
    //overrides and implementation logic here
}

Listing 18-1Outline of a service application in Android

通过回调管理服务生命周期

Service类及其子类提供了一系列回调,旨在让您用自己的逻辑实现服务控制行为。Service回调和生命周期概念非常类似于我们在第 11 和 12 章中探索的ActivityFragment生命周期,主要区别在于服务发现自己所处的状态更少。这种简单性意味着在编码支持逻辑时,处理服务行为时只需要考虑五个主要回调:

  1. onCreate():非常类似于活动的onCreate()方法,当服务活动的任何触发发生时,服务的onCreate()方法被调用。

  2. onStartCommand():当客户端应用调用相关的startService()方法时,调用onStartCommand()方法并处理其逻辑。

  3. onBind():当客户端应用试图通过bindService()调用绑定到服务时,每次都会调用onBind()方法。

  4. onTrimMemory():作为 Android 试图以一种自信的方式管理资源的一部分,当设备内存不足时,为资源回收选择的服务将调用它们的onTrimMemory()方法。在采取更激烈的措施之前,这为服务提供了一种更可控的方式来尝试返回内存。

  5. onDestroy():正常正常关机时,调用onDestroy()。就像正常活动一样,不能保证正常关机,因此也不能保证调用onDestroy()

你对来自活动和片段的生命周期管理的理解保持不变。这意味着您的服务应该在它的onCreate()调用期间创建它需要的东西,并在onDestroy()期间清理和处置任何延迟的资源,如果不是在此之前的话。

活动和服务之间的一个区别是,对于服务来说,没有onPause()onResume()的等价物。您的服务要么正在运行,要么没有运行。这意味着当服务被认为总是在后台时,没有必要提供后台转换方法。这种没有暂停/恢复的情况意味着您应该始终注意最小化服务保持的任何状态,或者在适当的情况下使用首选项或其他存储,以经受意外的服务终止。Android 不仅可以在任何时候为资源终止服务——绕过任何对onDestroy()的调用——而且用户还可以通过应用管理系统的设置活动终止你的服务。在这方面,绑定了客户端的服务变得更加复杂,我们稍后将在示例中探讨这一点。

为您的服务提供清单条目

要声明您的服务应用,您需要在AndroidManifest.xml文件中进行适当的声明。这从作为<application>元素的子元素的<service>元素的核心定义开始。清单 18-2 显示了服务的基本条目,包括必需的android:name属性,在本例中是“Skeleton”。

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

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

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >

        <!-- any other application child elements would go here -->

        <service android:name="Skeleton">

    </application>

</manifest>

Listing 18-2A minimal service definition in the AndroidManifest.xml file

您可以自由地在同一个项目中混合服务和活动的定义,因此在同一个AndroidManifest.xml文件中。您会发现,对于结合自己的服务开发的应用,通常都是这样做的。当您创建服务时,您并不总是希望允许任何应用绑定到它们并使用它们。在这些情况下,您可以在您的<service>元素中使用一个android:permission属性来限制访问。

服务通信

定义您的新服务并不比本章已经概述的步骤更复杂。一旦创建了服务,控制客户端应用(如活动和其他服务)如何与您的服务交互就有点复杂了。

与服务通信的客户端(活动或服务)采取两种可能的途径:启动命令或绑定。当涉及到您的服务与客户端进行通信时,事情会扩展到相当多的选项,您作为开发人员可以从中进行选择。

客户端到服务的通信

当任何类型的客户端想要使用服务时,无论该客户端是活动、片段还是其他服务,都有一个中心问题将指导您选择两种通信方法中的哪一种最适合该任务。这是一个一次性的、永不重复的为客户做某事的服务请求吗?在这些单一交互的情况下,服务通信的命令方法是最好的。如果客户端需要通过一系列操作来使用服务,同时保持与服务的持续交互,那么服务绑定就是一种方法。

Invoking Commands with startService()

让您的服务为客户端应用执行任务的最简单的方法是调用startService()方法,无论是活动还是其他来源。在第十七章中,我们介绍了startActivity()方法,它可以通过获取一个意图和一些参数来触发一个任意的活动,同样的,startService()方法也接受一个意图和一组额外的意图作为参数,让你传递一个上下文相关的有效载荷给接收服务。最基本的调用startService()形式如下:

startService(someSignatureIntent);

这种调用startService()的基本形式提供了预期的 Intent 类作为唯一的参数。调用startService()是异步的,应用的主线程——它管理主 UI——不会阻塞等待响应,而是会立即继续正常的生命周期。

调用startService()会触发 Android 启动未启动的服务,在已启动和待启动两个流中,Android 随后将 intent 从第一个参数传递给onStartCommand()方法。您的服务实现可以检查和使用意图,并且可以选择在第二个参数中使用数据负载,正如您在为onStartCommand()方法编写的逻辑中所希望的那样。

尽管调用应用没有被服务调用阻塞,但是您的服务将在其主线程中处理onStartCommand()方法,因此您应该注意不要承担太多繁重的处理、阻塞外部调用或任何其他可能妨碍快速响应的耗时工作。如果您确实需要将这种长时间运行的工作作为服务逻辑的一部分来执行,那么您可以探索使用 java.util.concurrent 包及其执行器和相关功能来添加更多的线程,以便在主服务线程之外执行这项工作。

使用startService()的“一劳永逸”特性意味着它不会向调用应用返回有效负载或正常意义上的响应。对于那些需要这样做的环境,服务到客户端的通信方法更合适,将在本章后面介绍。startService()调用确实返回一个值来表示该调用是成功完成还是由于资源匮乏或其他原因而被终止。该返回值来自一个预定义的小集合,从中您通常会看到以下内容:

  1. START_STICKY:一旦 Android 有足够的空闲内存来重新启动服务,但是不要担心触发意图,而是传递一个空意图。

  2. START_NON_STICKY:根本不要自动重启服务,即使 Android 资源压力降到足够低的水平允许。这意味着服务将不会启动,直到您的应用或其他程序显式调用startService()或再次调用对服务的需求。

  3. START _ REDELIVER _ INTENT:一旦 Android 有了足够的空闲内存,就重新启动服务,并尝试重新传递在进行原始(失败)调用时传递给服务的原始 INTENT 对象。

一旦服务启动,无论是从startService()调用还是其他方式,它都将无限期运行,除非设备上出现任何资源不足的情况,Android 可能会将其作为更广泛的资源管理的一部分杀死。这与这样一种观念是一致的,即一旦你的客户得到了它想要的东西,它并不真正关心服务之后会发生什么。从开发人员的角度来看,您可能会不时地考虑您的服务是否在做任何有用的事情,并且在缺少客户端需求的情况下,确定它没有任何事情可做,然后优雅地关闭。服务自行终止有两个主要选项,如下所示:

  1. 使用stopService()方法。与startService()类似,您调用stopService()时使用的参数与用于启动服务的参数或派生类的参数具有相同的意图。服务将被停止,所有资源将被释放,状态将被销毁。由于 Android 不跟踪对某项服务的startService()调用的来源或数量,它同样不区分哪个客户端发送了适当的stopService()调用。只需要一个stopService()命令,不管在服务的生命周期中接收了多少个startService()调用。

  2. 用一个stopSelf()调用来编码你的服务。您的服务可以控制自己的终结,您可能会将此视为服务执行工作的某种逻辑高潮的一部分,例如当音乐曲目播放完毕或文件下载完成时。

当构建您自己的服务时,请随意探索管理其终止的两种方法。或者,您可以将此留给 Android 的一般服务清理功能。如果您选择使用服务绑定作为处理服务的方法,则清理和关闭机制会有很大的不同。

对服务使用绑定方法

使用startService()与服务进行一次性交互的一次性方法是一种非常有用的机制。有时候,您会希望与服务进行多次交互,或者在更复杂的情况下,需要不止一次的交互,例如发送连续的命令或双向交换数据,以便您的应用可以为其用户执行一些有用的工作。

这就是使用bindService()的绑定方法介入的地方。绑定到服务会建立一个双向通信通道,这样您的应用就可以通过其绑定器访问服务的 API。绑定器是从bindService()调用返回到调用应用的对象,然后用于所有后续活动。使用绑定方法的客户端可以通过使用BIND_AUTO_CREATE标志向 Android 发出信号,如果服务当前停止,它希望该服务启动。与startService()方法的一个不同之处在于,一旦客户端释放了与服务的绑定,服务将被标记为可以关闭。我们将在本章后面讨论在这些情况下关机的机制。

Caution

当调用一个可能没有运行的服务时,如果在 bindService()调用中没有提供 BIND_AUTO_CREATE 标志,那么如果该服务还没有运行,该方法就有返回 false(并且没有提供 Binder 对象)的风险。这里的教训是不要依赖预感或假设的服务状态。相反,无论在什么情况下,都要练习干净的异常处理并检查 bindService()是否失败。

当 Android 面临内存压力时,使用BIND_ALLOW_OOM_MANAGEMENT标志可以帮助发出信号,表明您可以应对正在使用的服务的突然关闭。这个标志表示您的应用的绑定不是关键的,它可以容忍服务在内存不足的情况下突然消失。最简单地说,你是在表明,为了设备和运行在其上的所有其他应用的更大利益,你乐于牺牲你的绑定(和Binder对象)。

应用对bindService()的调用是一个异步调用,它包括用于识别服务的意图和可选的BIND_AUTO_CREATE标志。因为bindService()是异步的,所以在ServiceConnection对象被询问并且结果Binder对象从onBind()返回之前,你不会知道服务的结果和后续状态。一旦您确认这些已经被实例化并且可用,您就可以开始调用Binder方法并实际与服务的功能进行交互。对您来说,子类化Binder方法来实际实现您希望您的服务拥有的任何逻辑是正常的。

您的应用可以让ServiceConnection对象——以及到服务的隐式连接——存在多久就存在多久。当您完成了对服务的处理后,您调用unbindService()方法来指示您的服务绑定可以被释放,并且ServiceConnection对象和相关的资源也被释放。这种解除绑定最终会导致onServiceDisconnected()被调用,同时Binder对象也会被调用,这意味着您已经构建并通过类似 API 的方法呈现的任何定制逻辑都不应再被调用。如果任何其他应用已经绑定到该服务,您对unbindService()的调用将不会导致该服务停止。实际上,最后一个解除绑定的客户端应用会触发 Android 关闭服务。

服务到客户端的通信

到目前为止,我们已经在本章中探讨了服务,您可以看到命令方法和绑定方法是如何很好地支持客户端到服务的通信的。当涉及到从服务到客户端的通信时,有一系列选项可以涵盖几乎任何您可以想象的场景。让我们探索一下主要的选项,记住这些方法不像客户端到服务通信的startService()bindService()选项那样结构化。

对所有通信使用 bindService()方法

当考虑服务如何与它们感兴趣的客户通信时,首先要考虑的是通过bindService()和您为客户创建的方法进行交互。绑定方法的优点是,您可以精确地控制客户端从您的服务方法返回的对象和信息中接收到的内容,并保证客户端确实得到了它所要求的内容,因为客户端得到它所需要的内容的唯一方式是调用您的 API 方法。

这种方法的明显缺点是,不使用bindService()与您的服务建立持久双工通道的客户端将无法接收任何东西——任何通过startService()调用进行交互的客户端都只是运气不好!现在这听起来可能可以接受,但是设计您的服务来适应许多不同的客户端使用模式是一个好的实践。

意图和广播接收器

在第十七章中,我们介绍了 Android 在应用间通信的基本方法,包括意图和广播接收器机制。如前所述,您尝试了从代码中传播意图的示例。服务也可以自由使用这种方法!

在实践中,您通过在客户端应用中使用registerReceiver()方法注册一个BroadcastReceiver对象来利用意图和接收者,并使用它来从您将记录的服务命令中捕获特定于组件的广播,以便客户端可以正确地识别广播意图并根据需要处理它们。

采用意向方法有好处,但也有缺点,即意向必须是以行动为导向的,而不是依靠一些活动来自愿采取行动。您还假设客户端活动本身仍在使用其接收器运行,并且没有因为资源压力或其他设备上的事件而暂停或选择终止。

使用挂起内容对象

Android 提供了PendingIntent对象来表示需要执行的相关动作的意图。当涉及到服务时,一旦服务执行了它的工作,您的客户将调用onActivityResult()来处理下游逻辑。客户端通过startService()调用获取额外有效负载的能力,将PendingIntent对象传递给服务,然后服务通过调用其上的send()方法向客户端发出信号。

要使这种方法有效,还需要做额外的工作,因为您需要客户端代码来解释和识别使用了各种send()方法调用中的哪一个。

使用信使和消息对象

如果PendingIntent对象还不够,Android 还提供了Messenger对象用于上下文间的通信,比如从服务到活动。单个活动带有一个Handler对象,该对象可用于活动本身发送消息。然而,Handler并不公开用于活动到服务或活动内部的交互。这就是Messenger物体拯救世界的地方。它可以向任何Handler发送消息,因此可以用来桥接间隙并到达任何活动。

为了利用Messenger对象,您在调用服务之前添加一个额外的对象。服务以典型的方式接收意图,并可以提取Messenger对象,当需要与客户端通信时,服务应该创建并填充一个Message对象,然后调用信使的。send()将消息作为参数传递回客户端的方法。在您的客户端,您通过处理程序和它的handleMessage()方法接收这个消息。

虽然这在概念上非常简洁,但是还需要额外的步骤来创建和交换MessengerMessage对象。还有一个潜在的性能影响,因为您必须在活动的主应用线程中处理handleMessage()方法——所以保持这种处理是轻量级的!

使用独立的消息传递

除了我们在前面几节中介绍的各种 Android 原生和设备上的方法,您还可以通过外部消息传递或发布/订阅系统来处理服务到客户端的通信。随着 Google Cloud Messaging 及其克隆产品开发出越来越多有用的功能,这越来越受欢迎。使用这种第三方方法的时候通常是您的用例能够容忍异步消息传递的时候。

有关 Google Cloud Messaging 的更多细节,请查看开发者网站上的文档,网址为 https://developers.google.com/cloud-messaging/android

创建回调和侦听器

前面几节中的MessengerPendingIntent示例演示了将对象附加到传递给服务的额外意图是多么容易。使用这种方法要求您的对象是“Parcelable,”,并且您可以创建自己的对象来满足这种需求,包括您自己的回调或侦听器。

采用这种方法要求您定义侦听器对象,并在运行时让客户端和服务编码在需要通信时部署和处理侦听器。您还将负责注册和收回侦听器,这样您就不会留下任何孤儿以及随之而来的资源浪费/损失。

使用通知

服务本身没有直接的 UI,但是它可以与另一个应用的 UI 进行交互。回想一下我们在第十五章中介绍的通知主题,您可以看到服务如何利用它直接向用户呈现信息和响应。

服务在行动

我们已经讨论了 Android 的服务、客户端通信和整体行为的许多方面,所以现在是时候构建和运行您自己的示例服务和客户端应用了。为了有一个有用的例子,不需要用一整章来介绍它自己,我们将创建一个简单的照片共享服务和一个简单的客户端例子来将服务理论付诸实践。这不会是 Instagram 或 Flickr 的克隆——目标是简单,这样你就可以专注于设计服务。

选择服务设计

一个简单的照片共享应用是使用startService()服务交互模型的完美场景,在这里我们将向服务发送一个“请共享这个”指令,而不需要做任何需要绑定到服务的后续工作。我们将从基类Service中设计我们的服务,然后实现onStartCommand()方法和onBind()方法(即使我们的示例客户端没有使用它,我们仍然需要这样做来正确地扩展服务基类)。

我们可以选择另一个服务子类,比如IntentService,它为您提供了大部分的实现,比如整齐地调用stopService()startService()等等,但是由于我们不想要或者不需要这些助手,我们将坚持使用前面的方法。特别是,我希望这个服务在startService()呼叫之后继续存在,这样我们以后就可以停止分享了。

你选择分享哪些照片对这个例子来说并不重要,所以我在这个例子中把它们剔除了。如果你愿意的话,你可以修改这个并添加图片或其他资源。

为服务创建 Java 逻辑

ServiceExample的实现非常简单,主要关注基础Service类覆盖实现的核心部分,加上我为照片共享定制的特定于服务的逻辑。清单 18-3 具有来自ch18/ClientExample中的例子的完整服务实现。

package com.beginningandroid.clientexample;

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;

public class ServiceExample extends Service {
    public static final String EXTRA_ALBUM="EXTRA_ALBUM";
    private boolean isShared=false;

    @Override
    public IBinder onBind(Intent intent) {
        // We need to implement onBind as a Service subclass
        // In this case we do not actually need it, so can simply return
        return(null);
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        String album=intent.getStringExtra(EXTRA_ALBUM);
        startSharing(album);
        return(START_NOT_STICKY);
    }

    @Override
    public void onDestroy() {
        stopSharing(); }

    private void startSharing(String album) {
        if(!isShared) {
            // Simplified logic - you might have much more going on here
            Log.w(getClass().getName(), "Album successfully shared");
            isShared=true;
        }
    }

    private void stopSharing() {
        if(isShared) {
            // Simplified logic - you might have much more going on here
            Log.w(getClass().getName(), "Album sharing removed");
            isShared=false;
        }
    }
}

Listing 18-3Service implementation for ServiceExample.java

我们没有任何需要在服务启动时执行的特定于服务的设置,所以我们可以在一个onCreate()调用中省略额外的逻辑,并依靠父类来完成这项工作。我们实现了onStartCommand(),这样当客户端调用startService()时,我们可以采取期望的动作。这意味着我们想要检查用于指定服务的意图,并询问ServiceExample想要的额外内容,比如共享的相册名称。一旦我们有了专辑名,我们就调用为这个特定服务实现的startSharing()

如前所述,我已经完成了startSharing()方法的大部分后续部分。我们可以在这个例子中实现的一个有用的东西是使用 Android 的日志记录基础设施在不同的点发出相关信息,通知我们服务是活跃的和工作的,即使它缺少 UI。这种技术还可以帮助您在实际服务中进行各种调试、使用度量等等。从示例应用和服务中,您将能够通过 Logcat 中的输出来判断它正在运行并在服务逻辑的各个部分中移动。如果您什么也看不到,这也很有用——没有日志记录会提示您出现了问题。

我已经实现了onDestroy()方法,现在它只是调用我们服务的stopSharing()方法。就像startSharing()一样,这基本上被杜绝了,通过一些日志记录来帮助您确认服务代码在被调用时正在工作。

如前所述,即使我们不打算使用绑定方法,我们仍然需要基于我们的服务子类实现onBind()。在这种情况下,它可以返回 null。未来的增强可以允许其他客户端绑定和做更复杂的事情,如创建照片蒙太奇,显示相册的缩略图,等等。

创建一个示例客户端来使用服务

出于完整性的考虑,看到客户实际使用服务是件好事,而不是仅仅依赖于我的承诺,即服务做到了它所说的。清单 18-4 给出了客户端驱动ServiceExample服务的简单布局。

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

    <Button
        android:id="@+id/startSharing"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Start Sharing"
        android:onClick="onClick" />

    <Button
        android:id="@+id/stopSharing"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:text="Stop Sharing"
        android:layout_below="@id/startSharing"
        android:onClick="onClick" />

</RelativeLayout>

Listing 18-4The layout for the ClientExample application

你可以看到我已经创建了一个非常简单的 UI,因为ClientExample应用只有两个按钮,一个标记为“开始共享”,另一个标记为“停止共享”记住,这里的目标是理解服务机制,而不是 UI 美学。

因为我们使用命令风格的startService()方法来与服务交互,所以我们的 Java 逻辑也非常简单。清单 18-5 显示了完整的客户端逻辑。

package org.beginningandroid.clientexample;

import androidx.appcompat.app.AppCompatActivity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;

public class MainActivity extends AppCompatActivity {

    @Override

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    public void onClick(View view) {
        switch(view.getId()) {
            case R.id.startSharing:
                startSharing(view);
                break;
            case R.id.stopSharing:
                stopSharing(view);
                break;
        }
    }

    public void startSharing(View view) {
        Intent myIntent=new Intent(this, ServiceExample.class);
        myIntent.putExtra(ServiceExample.EXTRA_ALBUM, "My Holiday Snaps");
        startService(myIntent);
    }

    public void stopSharing(View v) {
        stopService(new Intent(this, ServiceExample.class));
    }

}

Listing 18-5Java code for the sample ClientExample application

到现在为止,你已经非常熟悉基本的onCreate()方法,它在ClientExample中只是扩大了布局。onClick()实现遵循通过查询传递的视图来检查用户点击了哪个按钮的模式,并根据需要触发startSharing()stopSharing()方法。

如果调用 s tartSharing(),它将实例化一个服务意图,为我们想要分享的一组照片传递一个非常可信的相册名称。使用传递意图的startService()调用服务。我们的stopSharing()实现基本上是用适当类型的新意图调用stopService()命令,与原始服务调用相匹配,从而将我们的服务作为关闭的目标。

测试运行中的服务

您可以继续测试运行服务并观察结果。确保将条目添加到您的清单中,例如:

<service android:name=".ServiceExample" />

当您运行ClientExample应用时,它触发对ServiceExample服务的调用,使您能够查看 Logcat 中的条目,如下所示:

...org.beginningandroid.clientexample.ServiceExample: Album successfully shared
...org.beginningandroid.clientexample.ServiceExample: Album sharing removed

摘要

现在,您已经探索了服务的所有基础知识,并准备将它们包含在未来的应用设计中。请务必尝试构建您自己的服务变体,这些变体使用我们在本章中探索的不同的服务到客户端的通信方法。

十九、在 Android 中处理文件

在这一章中,我们将详细探讨文件,包括 Android 为应用存储、检索和管理数据的方法。在下一章中,我们将讨论数据库的辅助工具,它们和文件一起代表了应用中数据管理的丰富选项。我们还将这些与内容供应器进行对比,内容供应器是 Android 更复杂的数据访问和管理模型。

本章中的例子集中在 Android 为基于文件的数据提供的两个主要方法上。方法 1 可以被认为是“应用嵌入式”模型,它使用与应用打包在一起的原始资源和素材。方法 2 是“Java I/O”方法,它利用几乎著名的 java.io 包来操作文件、数据流等,就像在任何其他操作系统上操作基于 Java 的文件管理一样。

每种方法都有优点和缺点,我们将一一介绍,您可以放心,没有最好的方法,只有对当前问题最好的方法。

使用资源和原始文件

在第 13 和 14 章中,我们介绍了一些音频和视频例子,这些例子依赖于 Android 的一些直接处理文件的功能。我们在这些章节的示例中探索的原始位置和素材位置的使用不仅限于音频、图像和视频等媒体文件。您可以将几乎任何类型的文件放在这些位置,只要您让开发人员知道如何访问和操作它们的内容。例如,您可以存储一个. csv 文件来保存一些有用的数据。

Android 通过 Resources 类及其getResources()方法提供了对文件的简单访问。对于原始资源文件,您可以通过调用openRawResources()方法通过InputStream来呈现其内容。作为开发人员,你的任务是知道InputStream中的数据意味着什么。在我们看一个例子之前,需要知道使用来自原始或素材文件的数据源的一些重要的优点和缺点。

基于 raw 的方法的优点包括:

  1. 多亏了安卓素材打包工具 AAPT,你的文件可以和你的应用打包在一起。

  2. 您可以在库项目中共同定位资源,以便在需要时可以从许多应用中访问它们。

  3. 默认情况下,文件是私有的,外部访问需要完整的知识或包名和资源名来引用或适当的库或 API 调用,以及在清单文件中或在运行时授予的文件访问权限。

  4. 只读和静态数据可以用常见的格式打包,比如 JSON 或 XML。

为了平衡优势,需要注意这种方法的一些主要缺点:

  1. 默认情况下为只读。编辑与应用打包在一起的现有资源并不简单。

  2. 对于其他应用或服务用户来说,共享是很重要的。

  3. 静态特性带来了保持信息更新的问题。

有了这些优点和缺点,您就可以做出明智的选择,确定这是否是您的应用和所需功能的正确方法。

从资源文件填充列表

理解文件管理的优点和缺点最好用一个例子来说明。对于这个例子,我们将引入ListView UI 小部件和适配器逻辑,并使用它们作为从 XML 文件读取数据的机制,并在应用运行时动态填充文件数据的值列表。清单 19-1 显示了一个简单的布局,提供了一个ListView来最终显示来自我们的 XML 资源文件的数据。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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">

    <TextView
        android:id="@+id/mySelection"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
    <ListView
        android:id="@android:id/list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:drawSelectorOnTop="false" />

</RelativeLayout>

Listing 19-1The layout for the RawFileExample

对于这个示例应用,我们将让我们的ListView显示颜色的名称,并从我们在ch19/RawFileExample项目中提供的 XML 文件colors.xml中获取这些颜色名称。你可以在清单 19-2 中看到文件colors.xml的内容。

<colors>
    <color value="red" />
    <color value="orange" />
    <color value="yellow" />
    <color value="green" />
    <color value="blue" />
    <color value="indigo" />
    <color value="violet" />
    <color value="black" />
    <color value="white" />
</colors>

Listing 19-2The colors.xml file content

可以看到colors.xml文件很简单,这是故意的。我们关注的是实际打开该文件、读取和解析其内容以及在应用的适当数据结构中使用结果数据所需的逻辑,而不是 XML 的复杂性。清单 19-3 展示了一个简单的基于ListActivity的应用的逻辑,它将在一个列表中显示来自colors.xml文件的颜色名称,然后让用户点击选择一种特定的颜色。

package org.beginningandroid.rawfileexample;

import android.app.ListActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

import java.io.InputStream;
import java.util.ArrayList;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;

public class MainActivity extends ListActivity {

    private TextView mySelection;
    ArrayList<String> colorItems=new ArrayList<String>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mySelection=(TextView)findViewById(R.id.mySelection);

        try {

            InputStream inStream=getResources().openRawResource(R.raw.colors);
            DocumentBuilder docBuild= DocumentBuilderFactory
                    .newInstance().newDocumentBuilder();
            Document myDoc=docBuild.parse(inStream, null);
            NodeList colors=myDoc.getElementsByTagName("color");
            for (int i=0;i<colors.getLength();i++) {
                colorItems.add(((Element)colors.item(i)).getAttribute("value"));
            }
            inStream.close();
        }
        catch (Exception e) {
            e.printStackTrace();
        }

        setListAdapter(new ArrayAdapter<String>(this,
                android.R.layout.simple_list_item_1, colorItems));
    }

    public void onListItemClick(ListView parent, View v, int position,
                                long id) {
        mySelection.setText(colorItems.get(position).toString());
    }

}

Listing 19-3RawFileExample Java logic for processing the XML resource file

查看RawFileExample的代码,您会立即注意到我们正在导入的处理文件 I/O 和 XML 解析的外部 Java 库的数量。这就是 Java 遗产在 Android 中的作用。即使您选择将 Kotlin 作为首选编程语言,大量的 Java 库也可以帮助您实现功能。

onCreate()方法首先创建一个InputStream对象,然后我们调用getResources().openRawResource()来执行在.apk中查找文件的动作,分配它的文件描述符,将它们与InputStream相关联,最后让系统准备好随后使用来自我们文件的数据流。从那时起,剩下的逻辑就是解释文件中的内容所需要的。

在初始文件处理之后,我们使用一个DocumentBuilder对象来解析文件的内容,并将结果表示存储在一个名为myDoc的文档对象中。使用 DOM 语义,我们调用getElementsByTagName()将所有的<color>元素收集到我们的NodeList对象中。考虑到我们文件的简单性,这看起来有些过分,但是想象一个更复杂的 XML 模式,包含其他元素、子元素等等,您可以看到这是如何有效地筛选出我们想要的元素的。

最后,我们使用 for 循环,遍历NodeList <color>条目,提取 value 属性的文本——这是我们想要在ListView中呈现的实际颜色名称字符串。填充了我们的NodeList后,我们可以用配置为使用颜色名称列表的ArrayAdapter来扩展ListView,要求它使用默认的simple_list_item_1内置 XML 布局来呈现结果。

处理用户单击颜色的逻辑检索颜色字符串,并用用户选择的条目填充TextView

运行应用会显示在我们的ListView中呈现的来自colors.xml文件的数据,如图 19-1 所示。

img/499629_1_En_19_Fig1_HTML.jpg

图 19-1

显示 XML 文件内容的 RawFileExample 应用

使用文件系统中的文件

如果您以前在传统文件系统上使用一般 Java 应用进行过文件 I/O,那么 Android 方法将会非常熟悉。对于那些不熟悉基于 Java 的文件读写的人,这里有一个快速介绍。

从 Java 的角度来看,文件被视为数据流,两个对象成为文件读写的中心:InputStreamOutputStream。这些流是通过从代码中调用openFileInput()openFileOutput()方法来提供的。有了流,你的程序逻辑就负责诸如从InputStream读取或者向OutputStream写入之类的动作,并且在你完成时清理所有的资源。

Android 的文件系统模型

由于 Android 的历史和谷歌对人们是否应该完全访问自己的设备过于家长式的想法,作为一名开发人员,当处理设备上的本地文件存储时,你将面临两个概念。所有存储都将分为“内部”和“外部”,但这些术语有一种扭曲的含义。在当代的 Android 中,“内部”主要是指你所想的,但“外部”既指传统的外部存储,如 SD 卡,也指普通人认为是内部的一部分板载存储,但 Android 称之为外部,表明你对传统的文件 I/O 有更自由的访问权。

除了将“内部”区域用于与系统相关的目的之外,在考虑 Android 下的文件系统时还存在其他差异,这些差异代表了内部和外部存储的优点和缺点。

内部存储如下:

  1. 在每一个 Android 设备上都可以找到,并且总是在适当的位置。

  2. 构成应用的一部分并被指定放在内部存储上的文件被视为应用不可或缺的一部分。这些文件在安装应用时安装,在删除应用时删除。

  3. 内部保存文件的默认安全边界是您的应用私有的。共享需要明确的附加步骤。

  4. 通常比可用的外部存储空间小得多,即使有足够的外部存储空间可用,用户也可以看到该存储空间已满,并出现空间管理问题。

外部存储不同,如下所示:

  1. Android 为外部存储提供了 USB 抽象层和接口。当用作 USB 设备时,设备上的应用无法访问外部存储器。

  2. 默认的安全边界是使外部存储上的所有文件都是全局可读的。其他应用可以读取您外部存储的文件,而无需开发人员或用户的知识或许可。

  3. 根据调用的 save 方法,卸载应用时可能不会移除外部存储的文件。

现在你已经了解了内部和外部存储的这些方面,请继续阅读!

读写文件的权限

如果您选择使用内部存储,那么您的应用总是有权写入和读取为其保留的内部存储部分。要查找应用的任何内部存储的详细信息,请调用getFilesDir()。您还可以使用getDir()返回一个命名的(子)目录供您使用,如果它还不存在,就在这个过程中创建它。

您可以通过调用openFileOutput()打开一个文件进行输出流——也称为写入。如果文件不存在,将为您创建一个。openFileInput()方法为一个InputStream执行文件打开,以满足您的读取要求,但是要注意,对于这个调用,您指定的文件必须已经存在。

openFileOutput()openFileInput()都接受许多控制文件和流行为的MODE_*选项。最常用的MODE_*选项包括

  • MODE_APPEND:文件中的现有数据不变,字符串中的数据被追加到文件中的现有内容。

  • MODE_PRIVATE:文件上的权限被设置为只允许创建它的应用(以及以相同用户身份运行的任何其他应用)访问该文件。这是默认设置。

  • MODE_WORLD_READABLE:向设备上的所有应用和用户开放读取权限。这被认为是糟糕的安全实践,但当使用内容供应器或服务被认为是过度时,经常会出现这种情况。

  • MODE_WORLD_WRITABLE:比全局可读更危险的是全局可写。任何应用或用户都可以写入该文件。仅仅因为其他开发者使用这个并不意味着你应该这样做!

对于您的应用用户而言,在应用分配的内部文件系统空间内创建、打开或写入内部文件不需要特定权限。创建存储在内部设备存储器中的文件的最简单示例如下:

FILE myFile = new FILE(context.getFilesDir(), "myFileName");

当您开始使用外部存储时,情况会有所不同。您可以使用不同的方法,权限模型严格执行适当的控制和保护措施。为了写入外部存储器,你的 Android 清单需要包含特权android.permission.WRITE_EXTERNAL_STORAGE,正如我们在第十三章和第十四章的音频和视频示例中看到的。

旧版本的 Android,直到 Android Marshmallow,允许你的应用自由地从外部存储器读取数据,而不需要指定或要求任何特殊的许可。对于最新版本的 Android,您需要在您的清单中包含android.permission.READ_EXTERNAL_STORAGE。因为包含这个对旧版本没有影响,所以不管您的版本支持计划如何,您应该简单地默认添加这个。

可用于外部存储访问的方法在名称上与前面介绍的用于内部存储的方法非常相似,但倾向于添加“外部”或“公共”一词getExternalStoragePublicDirectory()方法设计用于分配结构良好的目录和文件,您可以将文档、音频、图片、视频等存储在其中。该方法采用一个表示预定义应用目录之一的枚举和您选择的文件名。

Android 有几十个应用目录,包括

  • DIRECTORY_DOCUMENTS:用于存储用户创建的传统文本或其他可编辑文档。

  • DIRECTORY_MUSIC:存放各种音乐和音频文件的地方。

  • DIRECTORY_PICTURES:用于存储静态图像文件,如照片、绘图等。

所有这些预定义的位置都很有帮助,在它们非常适合的情况下,这些位置具有令人放心的可预测性,但有时您需要存储明显不同类型的文件。对于这些情况,使用通用的getExternalStorageDirectory()方法,提供与本章前面提到的用于内部存储的getFilesDir()相似的功能。

检查运行中的外部文件

消化了更多的理论之后,是时候用一个工作示例来探索外部文件了。在ch19/ExternalFilesExample中找到的ExternalFilesExample应用,通过保存文件和读回其内容的机制。

图 19-2 是用于提供文本输入域、文件写入和读取按钮以及文本读取域的布局。相应的布局 XML 文件在ch19/ExternalFilesExample项目中,但是我们将通过不在这里重复它来节省一些空间。

img/499629_1_En_19_Fig2_HTML.jpg

图 19-2

具有用于测试外部文件管理的字段和按钮的活动

我们的应用的支持逻辑遵循我多次使用的模式,一个中央onClick()方法接收按钮点击,根据用户在运行时选择的视图(按钮)切换到适当的方法。代码如清单 19-4 所示。

package org.beginningandroid.externalfilesexample;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Context;
import android.os.Bundle;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;

public class MainActivity extends AppCompatActivity {

    public final static String FILENAME="ExternalFilesExample.txt";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    public void onClick(View view) {
        switch(view.getId()) {
            case R.id.btnRead:
                try {
                    doReadFromFile();
                }
                catch (Exception e) {
                    e.printStackTrace();
                }
                break;
            case R.id.btnSave:
                doSaveToFile();
                break;
        }
    }

    public void doReadFromFile() throws Exception {
        doHideKeyboard();
        EditText readField;
        readField=(EditText)findViewById(R.id.editTextRead);
        try {
            InputStream inStrm=openFileInput(FILENAME);
            if (inStrm!=null) {
                // We will use the traditional Java I/O streams and builders.
                // This is cumbersome, and we'll return with a better version
                // in chapter 20 using the IOUtils external library

                InputStreamReader inStrmRdr=new InputStreamReader(inStrm);
                BufferedReader buffRdr=new BufferedReader(inStrmRdr);
                String fileContent;
                StringBuilder strBldr=new StringBuilder();

                while ((fileContent=buffRdr.readLine())!=null) {
                    strBldr.append(fileContent);
                }
                inStrm.close();
                readField.setText(strBldr.toString());
            }

        }
        catch (Throwable t) {
            // perform exception handling here
        }
    }

    public void doSaveToFile() {
        doHideKeyboard();
        EditText saveField;
        saveField=(EditText)findViewById(R.id.editText);
        try {
            OutputStreamWriter outStrm=
                    new OutputStreamWriter(openFileOutput
                            (FILENAME, Context.MODE_PRIVATE));
            try {
                outStrm.write(saveField.getText().toString());
            }
            catch (IOException i) {
                i.printStackTrace();
            }
            outStrm.close();
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void doHideKeyboard() {
        View view = this.getCurrentFocus();
        if (view != null) {
            InputMethodManager myIMM=(InputMethodManager)
                    this.getSystemService(Context.INPUT_METHOD_SERVICE);
            myIMM.hideSoftInputFromWindow
                    (view.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS);
        }
    }

}

Listing 19-4The ExternalFilesExample Java code

保存和读取文件需要什么

探索ExternalFilesExample项目,我们看到两个关键方法。首先是doSaveToFile()方法,它通过调用doHideKeyboard()(稍后介绍)来执行一些准备和内务处理,然后创建局部saveField变量并将其绑定到布局中的EditText视图。这样做是为了我们最终可以引用 UI 中的文本进行保存。

随后是主 try/catch 块,定义输出流,用于将文本传输到由变量FILENAME指定的文件。然后,我们调用.write()方法,尝试通过流将文本写入文件。

您可能会注意到在ExternalFilesExample代码中有许多嵌套的异常处理层。写入文件可能会遇到很多很多问题,从完全存储到用户在写入过程中自发移除正在写入的 SD 卡!简而言之,对于文件访问,对异常要格外小心。

第二,为了从文件中读取,我们使用了doReadFromFile()方法,遵循与我们使用doSaveToFile()方法相似的设置工作。我们首先调用doHideKeyboard()(下面将会介绍),然后本地变量readField被创建并绑定到editTextRead小部件。这将用于显示文件读取后的内容。

接下来,我们添加一个try/catch块,它包含了一些教科书上的 Java 文件处理。我们使用流读取器来访问文件,并传递缓冲区以允许消费者控制对数据的访问。缓冲区用于通过 while 块逐行访问流,我们逐渐在字符串生成器中构建文件的完整内容。从流(以及文件)中读取所有行后,流被关闭,然后我们通过 strBldr 对象将所有内容从缓冲区传输到布局中的readField EditText小部件。

有更精简、更现代的方法来完成所有这些,但关键是它们隐藏了正在发生的事情的基本机制。在ExternalFilesExample代码中,您可以看到 Java I/O 如何在最底层发生的混乱细节,以建立对所需对象和工作以及所有可能出错的地方的评估!没有一个头脑正常的人会像今天这样暴露文件访问的编程模式——他们会把它藏起来,即使在幕后所有这些步骤仍然会发生。

帮助简化 ime

我们的代码稍微偏离了使用doHideKeyboard()方法的严格文件处理。这是一个非常有用的辅助方法,它有助于减少用户在输入文本和执行所需操作时所需的步骤。当用户在EditText字段中输入文本时,IME 被触发,并显示软键盘供用户输入他们想要的文本。我们可以定制 IME,使用 IME 的“附件按钮”选项添加一个“完成”按钮,但这是一个额外的询问用户的按键。

相反,我精心设计了布局,以确保保存(和读取)按钮即使在 IME 处于活动状态时也是可见的,这意味着用户可以键入,然后立即单击保存按钮。对doSaveToFile()的调用调用doHideKeyboard(),它首先确定用户与哪个View进行了交互,以及输入法框架是否处于活动状态并显示键盘。如果显示了一个,我们调用.hideSoftInputFromWindow()来隐藏键盘。虽然用户看不到所有这些机制,但他们受益于用户体验中获得的简单性——少按一次键就可以保存他们的文件!

正在保存和读取文件

既然您已经理解了这个ExternalFilesExample例子,那么是时候看看它是如何实现的了。图 19-3 显示了当用户第一次开始在顶部字段输入文本时,显示屏最初是如何寻找应用的。

img/499629_1_En_19_Fig3_HTML.jpg

图 19-3

输入要保存到外部文件的文本

正如我所承诺的,IME(键盘)出现在屏幕的下半部分,但我们的按钮仍然可以使用。在这个例子中,这更像是一个黑客——它不是一个完全成熟的应用会使用的光鲜亮丽的 UI,而是显示了我们关心的文件 I/O。用户可以随时点击“保存到文件”按钮,触发doSaveToFile()方法。如本章前面所述,这调用了doHideKeyboard()方法,此时我们的用户界面将如图 19-4 所示。

img/499629_1_En_19_Fig4_HTML.jpg

图 19-4

IME 随着文件的保存而隐藏

输入到EditText字段的文本保存在一个名为ExternalFilesExample.txt的文件中。点击“从文件中读取”按钮,可以随时调出ExternalFilesExample.txt的内容。这将触发文件的内容被读取,然后通过doReadFromFile()方法显示。图 19-5 显示了此次文件检索的结果。

img/499629_1_En_19_Fig5_HTML.jpg

图 19-5

调出外部文件的内容

确保外部存储在需要时可用

当我在前面介绍使用外部存储时,我概述了一些潜在的缺点,包括您是否可以在需要时依赖它的不确定性。你的用户可以做一些疯狂的事情,比如从他们的设备中物理移除 SD 卡,甚至对于那些通过内部内存分区模仿外部存储的设备,Android 仍然允许将外部存储作为 USB 设备安装在其他地方,这隐含地切断了其他应用对存储的访问。

作为开发人员,您的目标应该是创建行为良好的应用,即使您的用户并不是这样!这意味着在应用尝试使用外部存储之前,对外部存储的存在和可用性进行健全性检查是明智的。

为此,Android 提供了一些有用的环境方法,其中最有用的是Environment.getExternalStorageState(),它从一个预定义的 enum 返回一个字符串,描述外部存储的当前状态。您可以使用此状态来确定外部存储的可用性、健康状况等。返回的常见值包括

  • MEDIA_BAD_REMOVAL:此状态表示物理 SD 卡在卸载前已被移除,由于缓存页面未被刷新,可能会使文件处于不一致状态(请参阅本章后面的文件系统讨论)。

  • MEDIA_REMOVED:当没有从板载设备映射外部存储并且不存在 SD 卡时,返回该值。

  • MEDIA_SHARED:当设备将其外部存储作为 USB 设备安装到某个其他外部平台时,这是返回的值,指示此时外部存储不可用,即使它存在于设备中。

  • MEDIA_CHECKING:当插入 SD 卡时,会执行检查以确定该卡是否已被格式化,如果是,则使用哪个文件系统。这是这些过程发生时返回的值。

  • MEDIA_MOUNTED:可以使用的外部存储器的正常状态。

  • MEDIA_MOUNTED_READ_ONLY:通常在 SD 卡的物理开关设置为只读位置时出现,这意味着不能写入外部存储的该部分。

developer.android.com 的 Android 文档有所有可能的外部存储状态值的完整列表。

Android 文件系统的其他考虑事项

现在,您已经熟悉了在 Android 中处理文件的各种方法,要确保在文件系统中使用文件的长期可行性,需要考虑一些微妙和不那么微妙的管理问题。

历史上的 Android 文件系统

在 Android 作为智能手机操作系统的历史上,它支持一系列板载存储文件系统标准。历史上的三种主要形式是

  1. YAFFS,或另一个闪存文件系统:基于 NAND 的存储的原始文件系统,它提供了许多有用的好处,包括磨损平衡支持,以便管理闪存存储随时间的衰减,并在一定程度上对操作系统和应用隐藏,以及文件系统级垃圾收集工具,以帮助将存储的坏区域移动到“死池”,而不是用于有意义的存储。

  2. YAFFS2 和 YAFFS 的进化和调整版本:为底层存储提供更好的长期健康管理。

  3. EXT4,Linux 普及的文件系统:具有成熟文件系统的所有“成熟”管理特性,包括每个文件的锁定语义、权限等等。

基于旧的YAFFSYAFFS2的文件系统以及使用它们的设备的一个问题是缺少文件锁定语义。简而言之,作为开发人员,两者都没有提供锁定单个文件的选项(例如,当编辑共享文件时),相反,您依赖于锁定“整个文件系统”来确保一致的访问。这有一系列的缺点,从阻止其他可能试图同时写入文件的应用,到如果文件 I/O 发生在主线程上,会妨碍有效的 UI 行为。

作为开发人员,您的主要问题是不知道用户的设备可能使用什么文件系统。你很可能会因为 I/O 锁定和阻塞问题而受到性能不佳的指责,即使可能是 Android 本身导致了这个问题。

避免文件 I/O 的 UI 问题

作为开发人员,您可以使用一系列技术来缓解与YAFFSYAFFS2文件系统有关的锁定和争用问题。这些技术通常也可以帮助对网络端点的其他类型的 I/O。

使用 StrictMode 分析应用

Android 生态系统提供了一系列工具来帮助执行应用行为和性能。StrictMode 策略工具就是这样一种工具,它通过分析所有代码的操作来寻找策略中定义的问题,从而帮助解决任何 I/O 延迟问题。

StrictMode 有一系列可用的策略,尽管您可能会发现自己正在使用它的两个原始产品。第一个策略是虚拟机策略,它涵盖了整个应用中通常不良的行为或实践,比如泄漏数据库连接对象。第二组策略是线程策略,这些策略特别关注在主 UI 线程上出现的表现不佳的代码。这有助于发现那些会降低或干扰用户流畅的用户界面体验的代码——无论是你的还是安卓的。

您可以通过从 onCreate()回调调用静态的StrictMode.enableDefaults()方法来激活 StrictMode 策略。调用这个调用将在 Logcat 输出中报告一系列关于 UI 线程问题的有用信息,包括文件 I/O 问题。如果你愿意,你也可以定义你自己的策略——具体细节超出了本书的范围,但是如果你感兴趣的话,Android 文档有更多的细节。

Caution

尽管 StrictMode 策略非常有用,但千万不要在最终发布的代码和应用中定义它们。保留 StrictMode 将在用户的设备上创建大量的日志数据,消耗掉您一直在努力管理的文件系统空间。

将逻辑移动到异步线程

前面关于 StrictMode 的讨论打开了将逻辑从应用的主 UI 线程和接口移开的世界。几乎在任何时候,都值得考虑应用中是否有其他逻辑不需要发生在关键路径上,例如从在线服务中后台查找数据、消息传递或发布/订阅样式的通知、缓存的项目等等。

关键路径之外可能发生的任何事情都应该被考虑用于异步操作,这就是 Android 的AsyncTask的亮点,能够产生额外的线程来处理您扔给它的任何逻辑。作为 Android 学习的一部分,这是非常值得掌握的,因为大多数开发人员将它作为管理应用线程的主要工具。

AsyncTask类是以一种形式提供的,这意味着作为开发人员,您必须对它进行子类化,以便为您想要做的工作创建特定的实现。这是有道理的,因为 Android 不能提前知道你的应用的细节,也不能覆盖全世界开发者希望它处理的数百万个任务。要使用AsyncTask,您需要获取它提供的doInBackground()方法,并实现您想要在另一个线程上执行的实际逻辑。您可以实现一些可选的附加方法来提供执行前和执行后的逻辑,以可控的方式与 UI 进行交互,等等。

清单 19-5 给出了一个存根,显示了AsyncTask的子类,以说明如何使用它来执行文件保存操作。有无数的其他方式可以实现这一点,但你会欣赏整体的想法。

private class SmartFileSaver extends AsyncTask<Void, Void, Void> {

    protected void onPreExecute() {
        // This method will fire on the UI thread
        // Show a Toast message
        Toast.makeText(this, "Saving File", Toast.LENGTH_LONG).show();
    }

    protected void doInBackground() {
        // This method will spawn a background thread
        // All work happens off the UI thread
        // create output stream
        // call .write()
        // catch exceptions
        // etc.
    }

    protected void onPostExecute() {
        // This method will fire on the UI thread
        // Show a Toast message
        Toast.makeText(this, "File Saved", Toast.LENGTH_LONG).show();
    }

}

Listing 19-5An example AsyncTask subclassing

使用SmartFileSaver.execute()方法将调用我们的各种onPreExecute()doInBackground()onPostExecute()方法,由 Android 管理相关的线程生存期和 UI 交互。

摘要

现在,您已经对 Android 下的文件 I/O 的基本机制有了一个很好的了解,特别是对文件系统、文件处理、流和文件内容机制有了一个基本的了解,这些都是处理文件的方法的一部分。