安卓-Kotlin-高级教程-五-

157 阅读30分钟

安卓 Kotlin 高级教程(五)

原文:Pro Android with Kotlin

协议:CC BY-NC-SA 4.0

十三、硬件

Android 能做的不仅仅是在智能手机上呈现 GUI。Android 还与可穿戴设备有关,与适当配置的电视机和汽车中的信息娱乐系统有关。智能手机也有摄像头、NFC 和蓝牙适配器,以及位置、移动、方向和指纹传感器。是的,智能手机也可以打电话。本章介绍了 Android 操作系统如何在智能手机以外的设备上运行,以及如何与设备的硬件进行交互。

使用可穿戴设备编程

Google Wear 是关于你穿在身上的小设备。虽然目前这种设备仅限于你买的戴在手腕上的智能手表,但未来的设备可能包括你的眼镜、衣服或任何你能想到的东西。目前,使用 Google Wear 意味着随身携带一部智能手机,并通过某种配对机制将其连接到 Google Wear 设备,但现代设备也可能以独立的方式运行。这意味着他们不再需要配对的智能手机,他们自己可以通过 Wi-Fi、蓝牙或蜂窝适配器连接到互联网、蜂窝网络或本地网络。

如果你碰巧使用配对的智能手机来运行 Google Wear 应用,这不再局限于只运行 Android,所以你可以将 Google Wear 设备与 Android 智能手机或苹果 iOS 手机配对。Android Wear OS 适用于运行 Android 4.4 或更高版本和 iOS 9.3 或更高版本的配对手机。

谷歌对智能手机应用的设计准则(更准确地说,是对简单而富于表现力的用户界面的需求)对穿戴应用更为重要。由于有限的空间和输入能力,将 UI 元素和前端工作流减少到最低限度对于与穿戴相关的开发来说是绝对重要的。否则,你的应用的可用性和可接受性就有显著降低的风险。

以下是 Google Wear 应用的常见使用案例:

  • 设计自己的表盘(时间和日期显示)

  • 添加复杂面(自定义面元素)

  • 显示通知

  • 信息发送

  • 语音交互

  • 谷歌助手

  • 播放音乐

  • 拨打和接听电话

  • 警告

  • 具有简单用户界面的应用

  • 智能手机和平板电脑应用的配套应用

  • 传感器应用

  • 基于位置的服务

  • 付费应用

在接下来的章节中,我们将关注 Google Wear 应用的开发事宜。

可穿戴设备的发展

虽然开发穿戴应用时,你可以使用与智能手机或平板电脑应用开发相同的工具和技术,但你必须记住智能手表的空间有限,以及用户与手表的交互方式与其他设备不同。

尽管如此,开始 Wear 开发的主要地方是 Android Studio,在这一节中,我们将描述如何设置您的 IDE 来开始 Wear 开发,以及如何将设备连接到 Android Studio。

开发穿戴类 app,首先要指出有两种操作模式。

  • 可穿戴设备与智能手机配对

    由于技术限制,无法将虚拟智能手表与虚拟智能手机配对。所以,你必须使用真实的手机来配对虚拟智能手表。

  • 单机模式

    Wear 应用可以独立运行,无需与智能手机配对。强烈建议现代应用在独立模式下也能做明智的事情。

在这两种情况下,创建一个新的 Android Studio 项目,并在 Target Android Devices 部分中,只选择 Wear box。作为最低 API 级别,选择 API 24。在随后的屏幕上,选择以下选项之一:

  • 不添加活动

    继续操作,不添加活动。您将不得不稍后手动执行该操作。

  • 空白磨损活动

    添加以下内容作为布局:

<android.support.wear.widget.BoxInsetLayout ...>
  <FrameLayout ...>
  </FrameLayout>
</android.support.wear.widget.BoxInsetLayout>

添加以下内容作为活动类:

  • 谷歌地图佩戴活动

    添加以下内容作为布局:

class MainActivity : WearableActivity() {
  override
  fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    setAmbientEnabled() // Enables Always-on
  }
}

<android.support.wear.widget.
      SwipeDismissFrameLayout ...>
  <FrameLayout ...>
      <fragment android:id="@+id/map" android:name=
          "com.google.android.gms.maps.MapFragment"
      ... />
    </FrameLayout>
  </android.support.wear.widget.
        SwipeDismissFrameLayout>

添加以下内容作为活动类:

  • 看脸

    此选项不创建活动;相反,它构建了一个定义手表外观所需的服务类。

class MapsActivity : WearableActivity(),
      OnMapReadyCallback {
  override fun onCreate(savedState: Bundle?) {
    super.onCreate(savedState)
    setAmbientEnabled() // Enables always on
    setContentView(R.layout.activity_maps)

    // Enables the Swipe-To-Dismiss Gesture
    ...
    // Adjusts margins
    ...
  }
  override
  fun onMapReady(googleMap: GoogleMap) {
    ...
  }
}

这个选择对应于你选择的开发范式。您将创建以下内容之一:

  • 一个类似智能手机的应用,需要在手表上明确启动才能运行。这包括一款用于穿戴的谷歌地图应用。

  • 手表表面。这或多或少是一个图形设计问题;表盘是手表表面时间和日期的视觉外观。

  • 面部并发症。这是添加到面部的特征。

我们将在接下来的章节中讨论不同的发展路径。

接下来,打开工具➤ AVD 管理器并创建一个新的虚拟穿戴设备。你现在可以在虚拟穿戴设备上启动你的应用了。除非您选择不添加任何活动,否则模拟器应该已经在界面上显示了一个启动 UI。

要将虚拟手表与智能手机配对,请使用 USB 电缆将智能手机连接到开发 PC,使其成为开发设备(在系统设置底部的内部版本号上点击七次),然后使用谷歌应用在智能手机上安装 Wear OS。在您的开发 PC 上,通过以下方式设置通信:

./adb -d forward tcp:5601 tcp:5601

启动应用,并从菜单中选择连接到模拟器。

如果你想使用真正的智能手表进行开发,并需要调试功能,在线资源“调试 Wear OS 应用”显示了有关如何设置智能手表调试过程的更多信息。

可穿戴设备应用用户界面

在开始为 Wear 应用创建用户界面之前,请考虑使用一种内置机制,即通知或面部复杂功能,如以下章节所述。然而,如果你认为你的穿戴应用有必要展示自己的布局,不要只是复制智能手机应用的布局,并将其用于穿戴。相反,要构建真正的 Wear 用户界面,请使用 Wear 支持库提供的特殊 UI 元素。要使用它们,确保模块的build.gradle文件包含以下内容:

dependencies {
  ...
  implementation 'com.android.support:wear:26.0.0'
}

这个库包含各种类,帮助您构建一个 UI,其中包含专门为 Wear 开发定制的元素。在线 API 文档中的“android.support.wear.widget”页面包含有关如何使用这些类的详细信息。

可穿戴面孔

如果您想要创建一个显示特定自定义设计中的时间和日期的磨损表面,请使用“观察表面”选项,从前面描述的项目创建向导开始。

警告

Android Studio 3.1 中提供的手表脸示例包含一个 bug。它试图启动一个默认的活动,该活动对于一个只显示人脸的应用来说是不存在的。要解决这个问题,请在“运行”菜单中打开“编辑配置”,在“启动选项”中将“启动”更改为“无”。

生成的向导服务类提供了一个非常精致的手表表面示例,您可以将它用作自己的表面的起点。

添加面部并发症

面部并发症是面部数据片段的占位符。复杂数据提供者与复杂数据呈现器是严格分开的,所以当你面对时,你不会说你想显示某些复杂数据。相反,您可以指定显示复杂情况的位置,还可以指定可能的复杂数据类型,但是您可以让用户决定在哪里显示哪些复杂情况。

在本节中,我们将讨论如何增强您的面部以显示并发症数据。为此,我提出了一种最小侵入性的方法来更新你的面部实现,以便你更容易实现自己的想法。如前所述,有一个运行面是本节的一个要求。

我们从AndroidManifest.xml中的条目开始。

  • 我们需要一种方式来告诉 Android,我们将为复杂的 UI 元素进行配置活动。这是通过在service元素中添加这个来实现的(移除由¬指示的换行符):
<meta-data
  android:name=
      "com.google.android.wearable. ¬
       watchface.wearableConfigurationAction"
  android:value=
      "com.example.xyz88.CONFIG_COMPLICATION"/>

这表明 Android 存在复杂的管理活动。我们将它映射到下一个描述的新活动。

  • 我们添加关于权限查询活动和配置活动的信息,如下所示:
<activity android:name=
      "android.support.wearable. ¬
      complications. ¬
      ComplicationHelperActivity"/>
<activity
    android:name=
      ".ComplicationConfigActivity"
      android:label="@string/app_name">
    <intent-filter>
      <action android:name=
          "com.example.xyz88\. ¬
          CONFIG_COMPLICATION"/>
      <category android:name=
          "com.google.android. ¬
          wearable.watchface.category. ¬
          WEARABLE_CONFIGURATION"/>
      <category android:name=
          "android.intent.category. ¬
          DEFAULT"/>
    </intent-filter>
</activity>

接下来,我们在Face类中任何合适的地方添加以下内容:

lateinit var compl : MyComplications

private fun initializeComplications() {
  compl = MyComplications()
  compl.init(this@MyWatchFace, this)
}

override
fun onComplicationDataUpdate(
      complicationId: Int,
      complicationData: ComplicationData)
{
      compl.onComplicationDataUpdate(
          complicationId,complicationData)
}

private fun drawComplications(
      canvas: Canvas, drawWhen: Long) {
  compl.drawComplications(canvas, drawWhen)
}

// Fires PendingIntent associated with
// complication (if it has one).
private fun onComplicationTap(
      complicationId:Int) {
  Log.d("LOG", "onComplicationTap()")
  compl.onComplicationTap(complicationId)
}

在同一文件中,将以下内容添加到ci.onCreate(...):

initializeComplications()

onSurfaceChanged(...)的末尾,添加以下内容:

compl.updateComplicationBounds(width, height)

onTapCommand(...)功能内,按如下方式替换相应的where块分支:

WatchFaceService.TAP_TYPE_TAP -> {
  // The user has completed the tap gesture.
  // Toast.makeText(applicationContext, R.string.message,
  Toast.LENGTH_SHORT)
  //        .show()
  compl.getTappedComplicationId(x, y)?.run  {
    onComplicationTap(this)
  }
}

这将判断用户是否点击了显示的复杂功能之一,如果是这样,则将事件转发给我们定义的新功能之一。最后,在onDraw(...)中,写下以下内容:

...
drawBackground(canvas)
drawComplications(canvas, now)
drawWatchFace(canvas)
...

为了处理这种复杂性,创建一个名为MyComplications的新类,其内容如下:

class MyComplications {

我们首先在 companion 对象中定义几个常量和实用方法。

companion object {
    fun getComplicationId(
          pos: ComplicationConfigActivity.
               ComplicationLocation): Int {
        // Add supported locations here
        return when(pos) {
            ComplicationConfigActivity.
                ComplicationLocation.LEFT ->
              LEFT_COMPLICATION_ID
            ComplicationConfigActivity.
                ComplicationLocation.RIGHT ->
              RIGHT_COMPLICATION_ID
            else -> -1
        }
    }

    fun getSupportedComplicationTypes(
          complicationLocation:
              ComplicationConfigActivity.
              ComplicationLocation): IntArray? {
        return when(complicationLocation) {
            ComplicationConfigActivity.
                  ComplicationLocation.LEFT ->
                COMPLICATION_SUPPORTED_TYPES[0]
            ComplicationConfigActivity.
                  ComplicationLocation.RIGHT ->
                COMPLICATION_SUPPORTED_TYPES[1]
            else -> IntArray(0)
        }
    }

    private val LEFT_COMPLICATION_ID = 0
    private val RIGHT_COMPLICATION_ID = 1
    val COMPLICATION_IDS = intArrayOf(
          LEFT_COMPLICATION_ID, RIGHT_COMPLICATION_ID)
    private val complicationDrawables =
          SparseArray<ComplicationDrawable>()
    private val complicationDat =
          SparseArray<ComplicationData>()

    // Left and right dial supported types.

    private val COMPLICATION_SUPPORTED_TYPES =
      arrayOf(
        intArrayOf(ComplicationData.TYPE_RANGED_VALUE,
                   ComplicationData.TYPE_ICON,
                   ComplicationData.TYPE_SHORT_TEXT,
                   ComplicationData.TYPE_SMALL_IMAGE),
        intArrayOf(ComplicationData.TYPE_RANGED_VALUE,
                   ComplicationData.TYPE_ICON,
                   ComplicationData.TYPE_SHORT_TEXT,
                   ComplicationData.TYPE_SMALL_IMAGE)
      )
}

private lateinit var ctx:CanvasWatchFaceService
private lateinit var engine:MyWatchFace.Engine

在一个init()方法中,我们注册了要绘制的复杂内容。方法onComplicationDataUpdate()用于处理并发症数据更新,方法updateComplicationBounds()对并发症大小变化做出反应。

fun init(ctx:CanvasWatchFaceService,
      engine: MyWatchFace.Engine) {
    this.ctx = ctx
    this.engine = engine

    // A ComplicationDrawable for each location
    val leftComplicationDrawable =
        ctx.getDrawable(custom_complication_styles)
        as ComplicationDrawable
    leftComplicationDrawable.setContext(
          ctx.applicationContext)
    val rightComplicationDrawable =
        ctx.getDrawable(custom_complication_styles)
        as ComplicationDrawable
    rightComplicationDrawable.setContext(
          ctx.applicationContext)
    complicationDrawables[LEFT_COMPLICATION_ID] =
          leftComplicationDrawable
    complicationDrawables[RIGHT_COMPLICATION_ID] =
          rightComplicationDrawable

    engine.setActiveComplications(*COMPLICATION_IDS)
}

fun onComplicationDataUpdate(
        complicationId: Int,
        complicationData: ComplicationData) {
    Log.d("LOG", "onComplicationDataUpdate() id: " +
          complicationId);
    complicationDat[complicationId] = complicationData
    complicationDrawables[complicationId].
          setComplicationData(complicationData)
    engine.invalidate()
}

fun updateComplicationBounds(width: Int,
      height: Int) {
    // For most Wear devices width and height
    // are the same
    val sizeOfComplication = width / 4
    val midpointOfScreen = width / 2

    val horizontalOffset =
          (midpointOfScreen - sizeOfComplication) / 2
    val verticalOffset =
          midpointOfScreen - sizeOfComplication / 2

    complicationDrawables.get(LEFT_COMPLICATION_ID).
          bounds =
            // Left, Top, Right, Bottom
            Rect(
              horizontalOffset,
              verticalOffset,
              horizontalOffset + sizeOfComplication,
              verticalOffset + sizeOfComplication)
    complicationDrawables.get(RIGHT_COMPLICATION_ID).
          bounds =
            // Left, Top, Right, Bottom
            Rect(
              midpointOfScreen + horizontalOffset,
              verticalOffset,
              midpointOfScreen + horizontalOffset +
                    sizeOfComplication,
              verticalOffset + sizeOfComplication)
}

方法drawComplications()实际上是画了复杂。为此,我们扫描了我们在init块中登记的并发症。

fun drawComplications(canvas: Canvas, drawWhen: Long) {
    COMPLICATION_IDS.forEach {
        complicationDrawables[it].
              draw(canvas, drawWhen)
    }
}

我们需要有能力发现我们的一个复杂问题是否被利用了。方法getTappedComplicationId()对此负责。最后,一个方法onComplicationTap()对此类事件做出反应。

// Determines if tap happened inside a complication
// area, or else returns null.
fun getTappedComplicationId(x:Int, y:Int):Int? {
    val currentTimeMillis = System.currentTimeMillis()
    for(complicationId in
          MyComplications.COMPLICATION_IDS) {
        val res =
              complicationDat[complicationId]?.run {
            var res2 = -1
            if(isActive(currentTimeMillis)
                && (getType() !=
                  ComplicationData.TYPE_NOT_CONFIGURED)
                && (getType() !=
                  ComplicationData.TYPE_EMPTY))
            {
                val complicationDrawable =
                  complicationDrawables[complicationId]
                val complicationBoundingRect =
                  complicationDrawable.bounds
                if (complicationBoundingRect.width()
                      > 0) {
                    if (complicationBoundingRect.
                        contains(x, y)) {
                    res2 = complicationId
                  }
              } else {
                  Log.e("LOG",
                  "Unrecognized complication id.")
              }
          }
          res2
      } ?: -1
      if(res != -1) return res
    }
    return null
}

// The user tapped on a complication
fun onComplicationTap(complicationId:Int) {
    Log.d("LOG", "onComplicationTap()")

    val complicationData =
          complicationDat[complicationId]
    if (complicationData != null) {
        if (complicationData.getTapAction()
              != null) {
            try {
                complicationData.getTapAction().send()
            } catch (e: Exception ) {
                Log.e("LOG",
                  "onComplicationTap() tap error: " +
                  e);
           }
       } else if (complicationData.getType() ==
             ComplicationData.TYPE_NO_PERMISSION) {
          // Launch permission request.
          val componentName = ComponentName(
                  ctx.applicationContext,
                  MyComplications::class.java)
          val permissionRequestIntent =
            ComplicationHelperActivity.
            createPermissionRequestHelperIntent(
                  ctx.applicationContext,
                  componentName)
            ctx.startActivity(permissionRequestIntent)
            }

        } else {
            Log.d("LOG",
                "No PendingIntent for complication " +
                complicationId + ".")
        }
    }
}

剩下要做的是编写配置活动。为此,创建一个名为ComplicationConfigActivity的新 Kotlin 类,其内容如下:

class ComplicationConfigActivity :
      Activity(), View.OnClickListener {
  companion object {
      val TAG = "LOG"
      val COMPLICATION_CONFIG_REQUEST_CODE = 1001
  }

  var mLeftComplicationId: Int = 0
  var mRightComplicationId: Int = 0
  var mSelectedComplicationId: Int = 0

  // Used to identify a specific service that renders
  // the watch face.
  var mWatchFaceComponentName: ComponentName? = null

  // Required to retrieve complication data from watch
  // face for preview.
  var mProviderInfoRetriever:
        ProviderInfoRetriever? = null

  var mLeftComplicationBackground: ImageView? = null
  var mRightComplicationBackground: ImageView? = null

  var mLeftComplication: ImageButton? = null
  var mRightComplication: ImageButton? = null

  var mDefaultAddComplicationDrawable: Drawable? = null

enum class ComplicationLocation {
    LEFT,
    RIGHT
}

像往常一样,我们使用onCreate()onDestroy()回调来设置或清理我们的用户界面。另外,onCreate()使用方法retrieveInitialComplicationsData()来初始化复杂情况。

override
fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContentView(R.layout.activity_config)

    mDefaultAddComplicationDrawable =
          getDrawable(R.drawable.add_complication)

    mSelectedComplicationId = -1

    mLeftComplicationId =
          MyComplications.getComplicationId(
          ComplicationLocation.LEFT)
    mRightComplicationId =
          MyComplications.getComplicationId(
          ComplicationLocation.RIGHT)

    mWatchFaceComponentName =
          ComponentName(applicationContext,
          MyWatchFace::class.java!!)

    // Sets up left complication preview.
    mLeftComplicationBackground =
          left_complication_background
    mLeftComplication = left_complication
    mLeftComplication!!.setOnClickListener(this)

    // Sets default as "Add Complication" icon.
    mLeftComplication!!.setImageDrawable(
          mDefaultAddComplicationDrawable)
    mLeftComplicationBackground!!.setVisibility(
          View.INVISIBLE)

    // Sets up right complication preview.
    mRightComplicationBackground =
          right_complication_background
    mRightComplication = right_complication
    mRightComplication!!.setOnClickListener(this)

    // Sets default as "Add Complication" icon.
    mRightComplication!!.setImageDrawable(
          mDefaultAddComplicationDrawable)
    mRightComplicationBackground!!.setVisibility(
          View.INVISIBLE)

    mProviderInfoRetriever =
          ProviderInfoRetriever(applicationContext,
          Executors.newCachedThreadPool())
    mProviderInfoRetriever!!.init()

    retrieveInitialComplicationsData()
}

override fun onDestroy() {
    super.onDestroy()
    mProviderInfoRetriever!!.release()
}

fun retrieveInitialComplicationsData() {
    val complicationIds =
          MyComplications.COMPLICATION_IDS
    mProviderInfoRetriever!!.retrieveProviderInfo(
            object : ProviderInfoRetriever.
                  OnProviderInfoReceivedCallback() {
                override fun onProviderInfoReceived(
                      watchFaceComplicationId:
                        Int,
                      complicationProviderInfo:
                        ComplicationProviderInfo?)
                {
                    Log.d(TAG,
                        "onProviderInfoReceived: " +
                        complicationProviderInfo)
                    updateComplicationViews(
                          watchFaceComplicationId,
                          complicationProviderInfo)
                }
            },
            mWatchFaceComponentName,
            *complicationIds)
}

方法onClick()launchComplicationHelperActivity()用于处理复杂攻丝。

override
fun onClick(view: View) {
    if (view.equals(mLeftComplication)) {
        Log.d(TAG, "Left Complication click()")
        launchComplicationHelperActivity(
              ComplicationLocation.LEFT)
    } else if (view.equals(mRightComplication)) {
        Log.d(TAG, "Right Complication click()")
        launchComplicationHelperActivity(
              ComplicationLocation.RIGHT)
    }
}

fun launchComplicationHelperActivity(
      complicationLocation: ComplicationLocation) {

    mSelectedComplicationId =
          MyComplications.getComplicationId(
          complicationLocation)

    if (mSelectedComplicationId >= 0) {
        val supportedTypes = MyComplications.
              getSupportedComplicationTypes(
              complicationLocation)!!

        startActivityForResult(
                ComplicationHelperActivity.
                createProviderChooserHelperIntent(
                        applicationContext,
                        mWatchFaceComponentName,
                        mSelectedComplicationId,
                        *supportedTypes),
                ComplicationConfigActivity.
                COMPLICATION_CONFIG_REQUEST_CODE)
    } else {
        Log.d(TAG,
          "Complication not supported by watch face.")
    }
}

为了处理 Android 操作系统发出的更新信号,我们提供了方法updateComplicationViews()onActivityResult()

fun updateComplicationViews(
        watchFaceComplicationId:
          Int,
        complicationProviderInfo:
          ComplicationProviderInfo?)
{
    Log.d(TAG, "updateComplicationViews(): id: "+
          watchFaceComplicationId)
    Log.d(TAG, "\tinfo: " + complicationProviderInfo)

    if (watchFaceComplicationId ==
          mLeftComplicationId) {
        if (complicationProviderInfo != null) {
            mLeftComplication!!.setImageIcon(
                complicationProviderInfo.providerIcon)
            mLeftComplicationBackground!!.
                setVisibility(View.VISIBLE)
        } else {
            mLeftComplication!!.setImageDrawable(
                mDefaultAddComplicationDrawable)
            mLeftComplicationBackground!!.
                setVisibility(View.INVISIBLE)
        }

    } else if (watchFaceComplicationId ==
          mRightComplicationId) {
        if (complicationProviderInfo != null) {
            mRightComplication!!.
                setImageIcon(
                complicationProviderInfo.providerIcon)
            mRightComplicationBackground!!.
                setVisibility(View.VISIBLE)

        } else {
            mRightComplication!!.setImageDrawable(

                mDefaultAddComplicationDrawable)
            mRightComplicationBackground!!.
                setVisibility(View.INVISIBLE)
        }
    }
}

override
fun onActivityResult(requestCode: Int,
      resultCode: Int, data: Intent) {
    if (requestCode ==
          COMPLICATION_CONFIG_REQUEST_CODE
        && resultCode == Activity.RESULT_OK) {

        // Retrieves information for selected
        //  Complication provider.
        val complicationProviderInfo =
            data.getParcelableExtra<
                ComplicationProviderInfo>(
                ProviderChooserIntent.
                EXTRA_PROVIDER_INFO)
        Log.d(TAG, "Provider: " +
            complicationProviderInfo)

        if (mSelectedComplicationId >= 0) {
            updateComplicationViews(
                mSelectedComplicationId,
                complicationProviderInfo)
        }
    }
  }
}

请注意,我们添加了几个日志记录语句,您可能希望删除这些语句以用于生产代码。相应的布局可以如下所示:

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

<View
    android:id="@+id/watch_face_background"
    android:layout_width="180dp"
    android:layout_height="180dp"
    android:layout_centerHorizontal="true"
    android:layout_centerVertical="true"
    android:background=
      "@drawable/settings_face_preview_background"/>

<View
    android:id="@+id/watch_face_highlight"
    android:layout_width="180dp"
    android:layout_height="180dp"
    android:layout_centerHorizontal="true"
    android:layout_centerVertical="true"
    android:background=
      "@drawable/settings_face_preview_highlight"/>

<View
    android:id="@+id/watch_face_arms_and_ticks"
    android:layout_width="180dp"
    android:layout_height="180dp"
    android:layout_centerHorizontal="true"
    android:layout_centerVertical="true"
    android:background=
      "@drawable/settings_face_preview_arms_n_ticks"/>

<ImageView
    android:id="@+id/left_complication_background"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:src="@drawable/added_complication"
    style="?android:borderlessButtonStyle"
    android:background="@android:color/transparent"
    android:layout_centerVertical="true"
    android:layout_alignStart=
        "@+id/watch_face_background"/>

<ImageButton
    android:id="@+id/left_complication"

    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    style="?android:borderlessButtonStyle"
    android:background="@android:color/transparent"
    android:layout_alignTop=
        "@+id/left_complication_background"
    android:layout_alignStart=
        "@+id/watch_face_background"/>

<ImageView
    android:id="@+id/right_complication_background"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:src="@drawable/added_complication"
    style="?android:borderlessButtonStyle"
    android:background="@android:color/transparent"
    android:layout_alignTop=
        "@+id/left_complication_background"
    android:layout_alignStart=
        "@+id/right_complication"/>

<ImageButton
    android:id="@+id/right_complication"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    style="?android:borderlessButtonStyle"
    android:background="@android:color/transparent"
    android:layout_alignTop=
        "@+id/right_complication_background"
    android:layout_alignEnd=
        "@+id/watch_face_background"/>
</RelativeLayout>

通过所有这些添加,该表面提供了两种可能的用户需求增加的复杂性。更多的复杂位置是可能的;只需重写代码的适当部分。

注意

输入这里显示的代码,Android Studio 会抱怨缺少资源,尤其是 drawables。要运行这里给出的代码,您必须提供缺少的资源。你通常可以通过查看名称来了解它们的用途。

提供并发症数据

默认情况下,Google Wear 设备包括几个复杂数据提供程序,因此用户可以在其中进行选择,以填充面部的复杂占位符。

如果您想创建自己的复杂数据提供者,请准备一个新的服务,如AndroidManifest.xml中所述。

<service
    android:name=".CustomComplicationProviderService"
    android:icon="@drawable/ic_watch_white"
    android:label="Service label"
    android:permission="com.google.android.wearable. ¬
          permission.BIND_COMPLICATION_PROVIDER">

  <intent-filter>
    <action android:name="android.support.wearable. ¬
          complications. ¬
          ACTION_COMPLICATION_UPDATE_REQUEST"/>
  </intent-filter>

  <meta-data
        android:name="android.support.wearable.¬
              complications.SUPPORTED_TYPES"
        android:value=
              "SHORT_TEXT,LONG_TEXT,RANGED_VALUE"/>

          <!--
          UPDATE_PERIOD_SECONDS specifies how
          often you want the system to check for updates
          to the data. A zero value means you will
           instead manually trigger updates.

           If not zero, set the interval in the order
           of minutes. The actual update may however
          differ - the system might have its own idea.
          -->
          <meta-data
              android:name="android.support.wearable.¬
                    complications.UPDATE_PERIOD_SECONDS"
              android:value="0"/>

      </service>

从服务等级CustomComplicationProviderService开始,如下所示:

class CustomComplicationProviderService :
      ComplicationProviderService() {
  // This method is for any one-time per complication set
 -up.
  override
  fun onComplicationActivated(
          complicationId: Int, dataType: Int,
         complicationManager: ComplicationManager?) {
      Log.d(TAG,
          "onComplicationActivated(): $complicationId")
  }

  // The complication needs updated data from your
  // provider. Could happen because of one of:
  //   1\. An active watch face complication is changed
  //      to use this provider
  //   2\. A complication using this provider becomes
  //      active
  //   3\. The UPDATE_PERIOD_SECONDS (manifest) has
  //      elapsed
  //   4\. Manually: an update via
  //      ProviderUpdateRequester.requestUpdate()
  override fun onComplicationUpdate(
          complicationId: Int, dataType: Int,
          complicationManager: ComplicationManager) {
      Log.d(TAG,
        "onComplicationUpdate() $complicationId")

      // ... add code for data generation ...

      var complicationData: ComplicationData? = null
      when (dataType) {
          ComplicationData.TYPE_SHORT_TEXT ->
             complicationData = ComplicationData.
                Builder(ComplicationData.TYPE_SHORT_TEXT)
                  . ... create datum ...
                  .build()
          ComplicationData.TYPE_LONG_TEXT ->
            complicationData = ComplicationData.
                Builder(ComplicationData.TYPE_LONG_TEXT)
                  ...
          ComplicationData.TYPE_RANGED_VALUE ->
             complicationData = ComplicationData.
                Builder(ComplicationData.
                        TYPE_RANGED_VALUE)
                  ...
          else ->
            Log.w("LOG",
            "Unexpected complication type $dataType")
}

      if (complicationData != null) {
           complicationManager.updateComplicationData(
                complicationId, complicationData)
      } else {
          // Even if no data is sent, we inform the
          // ComplicationManager
          complicationManager.noUpdateRequired(
                 complicationId)
      }
  }

  override
  fun onComplicationDeactivated(complicationId: Int) {
      Log.d("LOG",
        "onComplicationDeactivated(): $complicationId")
  }
}

要手动触发系统查询新的复杂数据的请求,可以使用如下的ProviderUpdateRequester类:

val compName =
  ComponentName(applicationContext,
      MyService::class.java)

val providerUpdateRequester =
  ProviderUpdateRequester(
      applicationContext, componentName)

providerUpdateRequester.requestUpdate(
    complicationId)
// To instead all complications, instead use
// providerUpdateRequester.requestUpdateAll()

关于可穿戴设备的通知

可穿戴设备上的通知可以在桥接模式和独立模式下运行。在桥接模式下,通知会自动与配对的智能手机同步;在独立模式下,穿戴设备独立显示通知。

要开始创建您自己的通知,请使用项目设置向导中的空白磨损活动从磨损项目开始。然后,在模块的build.gradle文件中,更新依赖关系,如下所示(删除¬处的换行符):

dependencies {
  implementation fileTree(dir: 'libs', include: ['*.jar
 '])
  implementation "org.jetbrains.kotlin: ¬
      kotlin-stdlib-jre7:$kotlin_version"
  implementation ¬
      'com.google.android.support:wearable:2.3.0'
  implementation 'com.google.android.gms: ¬
      play-services-wearable:12.0.1'
  implementation ¬
      'com.android.support:percent:27.1.1'
  implementation ¬
      'com.android.support:support-v13:27.1.1'
  implementation ¬
      'com.android.support:recyclerview-v7:27.1.1'
  implementation ¬
      'com.android.support:wear:27.1.1'
compileOnly ¬
      'com.google.android.wearable:wearable:2.3.0'
}

更改布局文件以添加用于创建通知的按钮,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<android.support.wear.widget.BoxInsetLayout
  xmlns:android=
      "http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:background="@color/dark_grey"
  android:padding="@dimen/box_inset_layout_padding"
  tools:context=".MainActivity"
  tools:deviceIds="wear">

  <LinearLayout
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:padding=
          "@dimen/inner_frame_layout_padding"
      app:boxedEdges="all"
      android:orientation="vertical">

      <TextView
      android:id="@+id/text"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="@string/hello_world"/>

      <Button
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:text="Go" android:onClick="go"/>

  </LinearLayout>
</android.support.wear.widget.BoxInsetLayout>

活动获取一个函数来对按钮的按下做出反应。在内部,我们创建并发送通知。

class MainActivity : WearableActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      setContentView(activity_main)
      setAmbientEnabled() // Enables Always-on
  }

  fun go(v: View) {
      val notificationId = 1

      // The channel ID of the notification.
      val id = "my_channel_01"
      if (Build.VERSION.SDK_INT >=
            Build.VERSION_CODES.O) {
          // Create the NotificationChannel
          val name = "My channel"
          val description = "Channel description"
          val importance =
                NotificationManager.IMPORTANCE_DEFAULT
          val mChannel = NotificationChannel(
                 id, name, importance)
          mChannel.description = description
          // Register the channel with the system
          val notificationManager = getSystemService(
                 Context.NOTIFICATION_SERVICE)
              as NotificationManager
          notificationManager.
                createNotificationChannel(mChannel)
      }

      // Notification channel ID is ignored for Android
      // 7.1.1 (API level 25) and lower.
      val notificationBuilder =
            NotificationCompat.Builder(this, id)
          .setSmallIcon(android.R.drawable.ic_media_play)
          .setContentTitle("Title")
          .setContentText("Content Text")

      // Get NotificationManager service
      val notificationManager =
            NotificationManagerCompat.from(this)

      // Issue the notification
      notificationManager.notify(
            notificationId, notificationBuilder.build())
   }
}

如果你启动这个应用,它会显示一个简单的用户界面,有一个文本和一个按钮。按下按钮会导致短时间显示通知图标,在我们的示例中是一个“播放”矩形。使用后退按钮并向上滑动,通知会显示标题和内容。此外,您的用户使用的面孔可能会添加通知预览。见图 13-1 。

img/463716_1_En_13_Fig1_HTML.jpg

图 13-1

磨损通知

您还可以在代码中添加一个PendingIntent,并在构建器中用setContentIntent(...)注册它,以便在用户单击一个出现的通知时发送一个意图。此外,在构建器中,您可以使用addAction(...)addActions(...)添加动作图标。

可以通过构造一个NotificationCompat.WearableExtender对象并调用构建器上的extent(...)来传递这个扩展器对象,从而将特定于可穿戴设备的功能添加到通知中。注意,通过向WearbleExtender对象而不是构建器添加动作,可以确保动作只在可穿戴设备上显示。

要使用预定义的文本响应和在桥接模式下使用的特殊功能为佩戴通知添加语音功能,请参阅佩戴通知的在线文档。

控制可穿戴设备上的应用可见性

自 Android 5.1 以来,穿戴操作系统设备允许在前台运行穿戴应用,即使在省电或环境模式下。处理环境模式有两种选择。

  • 使用AmbientModeSupport类。

  • 使用WearableActivity类。

要使用AmbientModeSupport类,实现Activity的一个子类,实现AmbientCallbackProvider接口,声明并保存AmbientController,如下所示:

class MainActivity : FragmentActivity(),
      AmbientModeSupport.AmbientCallbackProvider {
  override
  fun getAmbientCallback():
        AmbientModeSupport.AmbientCallback

  {
     ...
  }

  lateinit
  var mAmbientController:
        AmbientModeSupport.AmbientController

  override
  fun onCreate(savedInstanceState:Bundle?) {
      super.onCreate(savedInstanceState)
      ...
      mAmbientController =
          AmbientModeSupport.attach(this)
  }
}

getAmbientCallback()函数中,创建并返回AmbientModeSupport.AmbientCallback的子类。这个回调负责标准模式和环境模式之间的切换。作为开发人员,环境模式实际上做什么取决于您,但是您应该采用节能措施,例如暗显和黑白图形、增加更新间隔等等。

允许环境模式的第二种可能性是让你的活动从类WearableActivity继承,在onCreate(...)回调中调用setAmbientEnabled(),并覆盖onEnterAmbient()onExitAmbient()。如果你也覆盖了onUpdateAmbient(),你可以把你的屏幕更新逻辑放在那里,让系统决定在环境模式下使用哪个更新频率。

穿着认证

随着穿戴应用能够以独立模式运行,身份验证对于穿戴应用变得更加重要。描述这方面的适当程序超出了本书的范围,但是在线文档中的“穿戴认证”页面为您提供了有关穿戴认证的详细信息。

穿着时的语音功能

为穿戴式设备添加语音功能非常有意义,因为其他用户输入方法因设备尺寸较小而受到限制。您有两种选择:将您的应用连接到一个或多个系统提供的语音操作,或者定义您自己的操作。

警告

磨损模拟器无法处理语音命令;你必须使用真实的设备来测试它。

将系统语音事件与应用提供的活动联系起来非常简单。您所要做的就是将意图过滤器添加到您的活动中,如下所示:

<intent-filter>
    <action android:name=
          "android.intent.action.SEND" />
    <category android:name=
          "com.google.android.voicesearch.SELF_NOTE" />
</intent-filter>

表 13-1 列出了可能的语音键。

表 13-1

系统语音命令

|

命令

|

显示

|

关键附加功能

| | --- | --- | --- | | “好的,谷歌,帮我叫辆出租车”“好的,谷歌,帮我叫辆车” | com.google.android.gms.actions.RESERVE_TAXI_RESERVATION |   | | “好的,谷歌,记下来”“好吧,谷歌,自我提醒” | android.intent.action.SEND类别:com.android.voicesearch.SELF_NOTE | android.content.Intent.EXTRA_TEXT:带音符体的字符串 | | "好吧,谷歌,设置一个早上 8 点的闹钟."“好的,谷歌,明天早上 6 点叫醒我” | android.intent.action.SET_ALARM | android.provider.AlarmClock.EXTRA_HOUR:一个整数,表示闹铃的时间android.provider.AlarmClock.EXTRA_MINUTES:报警分钟的整数 | | “好的谷歌,设置一个 10 分钟的计时器” | android.intent.action.SET_TIMER | android.provider.AlarmClock.EXTRA_LENGTH:1 到 86400(24 小时内的秒数)范围内的整数,表示计时器的长度 | | “好吧谷歌,开始秒表” | com.google.android.wearable.action.STOPWATCH |   | | “好的谷歌,开始骑自行车”“好的,谷歌,开始我的骑行”“好了谷歌,别骑自行车了” | vnd.google.fitness.TRACKMIME 类型:vnd.google.fitness.activity/biking | actionStatus:启动时值为ActiveActionStatus,停止时值为CompletedActionStatus的字符串 | | “好的,谷歌,追踪我的跑步记录”“好的,谷歌,开始运行”“好了谷歌,别跑了” | vnd.google.fitness.TRACKMIME 类型:vnd.google.fitness.activity/running | actionStatus:启动时值为ActiveActionStatus,停止时值为CompletedActionStatus的字符串 | | “好的,谷歌,开始锻炼”“好吧,谷歌,跟踪我的训练”“好吧谷歌,停止锻炼” | vnd.google.fitness.TRACKMIME 类型:vnd.google.fitness.activity/other | actionStatus:启动时值为ActiveActionStatus,停止时值为CompletedActionStatus的字符串 | | “好的谷歌,我的心率是多少?”“好吧谷歌,我的 bpm 是什么?” | vnd.google.fitness.VIEWMIME 类型:vnd.google.fitness.data_type/com.google.heart_rate.bpm |   | | “好的谷歌,我走了多少步?”"好的,谷歌,我的步数是多少?" | vnd.google.fitness.VIEWMIME 类型:vnd.google.fitness.data_type/com.google.step_count.cumulative |   |

额外的数据可以像往常一样通过各种Intent.get*Extra(...)方法之一从输入的意图中提取出来。

您还可以提供应用定义的语音操作,以启动自定义活动。为此,在AndroidManifest.xml中,将每个有问题的<action>元素定义如下:

<activity android:name="MyActivity" android:label="
 MyRunningApp">
    <intent-filter>
        <action android:name="android.intent.action.MAIN"
  />
        <category android:name="android.intent.category.
  LAUNCHER" />
     </intent-filter>
</activity>

借助于label属性,您可以说“Start MyRunningApp”来启动活动。

您也可以让语音识别填写编辑字段。为此,请编写以下内容:

val SPEECH_REQUEST_CODE = 42
val intent = Intent(
      RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
  putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
        RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
}.run {
  startActivityForResult(this, SPEECH_REQUEST_CODE)
}

然后在被覆盖的onActivityResult(...)回调中获取结果。

fun onActivityResult(requestCode:Int, resultCode:Int,
      data:Intent) {
  if (requestCode and 0xFFFF == SPEECH_REQUEST_CODE
        && resultCode == RESULT_OK) {
      val results = data.getStringArrayListExtra(
               RecognizerIntent.EXTRA_RESULTS)
      String spokenText = results[0]
      // ... do something with spoken text
  }
  super.onActivityResult(
        requestCode, resultCode, data)
}

可穿戴设备上的扬声器

如果你想使用连接到 Wear 设备的扬声器来播放一些音频,你首先要检查 Wear 应用是否可以连接扬声器。

fun hasSpeakers(): Boolean {
    val packageManager = context.getPackageManager()
    val audioManager =
          context.getSystemService(
          Context.AUDIO_SERVICE) as AudioManager

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        // Check FEATURE_AUDIO_OUTPUT to guard against
        // false positives.
        if (!packageManager.hasSystemFeature(
              PackageManager.FEATURE_AUDIO_OUTPUT)) {
            return false
        }

        val devices =
               audioManager.getDevices(
               AudioManager.GET_DEVICES_OUTPUTS)
        for (device in devices) {
            if (device.type ==
                  AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
                return true
            }
       }
    }
    return false
}

然后,您可以像在任何其他设备上播放任何其他应用一样播放声音。这在“播放音频”一节中有详细描述

磨损位置

要在穿戴设备中使用位置检测,您必须首先检查位置数据是否可用。

fun hasGps():Boolean {
  return packageManager.hasSystemFeature(
        PackageManager.FEATURE_LOCATION_GPS);
}

如果可穿戴设备没有自己的位置传感器,您必须不断检查可穿戴设备是否连接。您可以通过如下方式处理回调:

var wearableConnected = false
fun onCreate(savedInstanceState: Bundle?) {
    ...
    Wearable.getNodeClient(this@MainActivity).
          connectedNodes.addOnSuccessListener {
        wearableConnected = it.any {
             it.isNearby
        }
    }.addOnCompleteListener {
    }.addOnFailureListener {
       ...
    }
}

从这里开始,您可以使用融合的位置提供器处理位置检测,如第八章所述。

穿着中的数据通信

Wear OS 中的数据通信有两种方式。

  • 直接网络通信:这是针对能够自己连接到网络的穿戴设备,希望与非配对设备通话。

  • 使用可穿戴数据层 API :用于与配对的手持设备通信。

对于直接网络通信,使用类 android.net .ConnectivityManager来检查诸如带宽之类的功能和请求诸如增加带宽之类的新功能。有关详细信息,请参见在线 API 文档中的类。要实际执行网络通信,使用包 android.net 中的类和接口。

本节的其余部分将描述用于与配对手持设备通信的可穿戴数据层 API

要访问可穿戴数据层 API,请从活动内部通过以下方式检索一个DataClientMessageClient:

val dataClient = Wearable.getDataClient(this)
val msgClient = Wearable.getMessageClient(this)

你可以经常这样做,因为这两个电话都不贵。消息客户端最适合用于负载较小的数据;对于更大的有效负载,请使用数据客户端。此外,数据客户端是在穿戴设备和手持设备之间同步数据的可靠方式,而消息客户端使用一劳永逸的模式。因此,消息客户端不知道发送的数据是否实际到达。

对于使用数据客户端发送数据项,创建一个PutDataMapRequest对象,对其调用getDataMap(),并使用各种put...()方法之一添加数据。最后,调用asPutDataRequest(),并用其结果调用DataClient.putDataItem(...)。后者启动与其他设备的同步,并返回一个com.google.android.gms.tasks.Task对象,您可以向其中添加监听器来监视通信。

在接收方,您可以通过使用DataClient.OnDataChangedListener扩展您的活动并实现fun onDataChanged(dataEvents:DataEventBuffer)函数来观察数据同步。

对于像图像这样的大型二进制数据集,您可以使用一个Asset作为要通过数据客户端发送的数据类型,如下所示:

fun createAssetFromBitmap(bitmap: Bitmap): Asset {
  val byteStream = ByteArrayOutputStream()
  bitmap.compress(Bitmap.CompressFormat.PNG, 100,
        byteStream)
  return Asset.createFromBytes(byteStream.
         toByteArray())
}
val bitmap = BitmapFactory.decodeResource(
      getResources(), android.R.drawable.ic_media_play)
val asset = createAssetFromBitmap(bitmap)
val dataMap = PutDataMapRequest.create("/image")
dataMap.getDataMap().putAsset("profileImage", asset)
val request = dataMap.asPutDataRequest()
val putTask: Task<DataItem> =
      Wearable.getDataClient(this).putDataItem(request)

要使用消息客户端,我们首先需要找到合适的消息接收者。为此,您首先要为合适的手持应用分配功能。这可以通过向res/values添加一个文件wear.xml来实现,该文件包含以下内容:

<resources>
  <string-array name="android_wear_capabilities">
      <item>my_capability1</item>
      <item>my_capability2</item>
      ...
  </string-array>
</resources>

要找到具有合适功能的手持设备(或网络节点),然后向其发送消息,您需要编写以下代码:

val capabilityInfo = Tasks.await(
    Wearable.getCapabilityClient(this).getCapability(
       "my_capability1",
       CapabilityClient.FILTER_REACHABLE))
capabilityInfo.nodes.find {
  it.isNearby
}?.run {
  msgClient.sendMessage(
        this.id,"/msg/path","Hello".toByteArray())
}

除此之外,您还可以直接向客户端添加一个CapabilityClient.OnCapabilityChangedListener监听器,如下所示:

Wearable.getCapabilityClient(this).addListener({
  it.nodes.find {
    it.isNearby
  }?.run {
    msgClient.sendMessage(
       this.id,"/msg/path","Hello".toByteArray())
  }
}, "my_capability1")

要接收这样的消息,在手持设备上安装的应用中的任何位置,通过以下方式注册消息事件监听器:

Wearable.getMessageClient(this).addListener {
      messageEvent ->
    // do s.th. with the message event
}

使用 Android 电视编程

针对 Android 电视设备的应用开发与智能手机上的开发没有本质区别。然而,由于几十年来电视消费带来的用户期望,与智能手机相比,惯例更加严格。幸运的是,Android Studio 的项目构建器向导可以帮助您入门。在这一部分,我们也将讨论 Android TV 开发的重要方面。

安卓电视使用案例

以下是安卓电视应用的典型使用案例:

  • 播放视频和音乐数据流和文件

  • 帮助用户查找内容的目录

  • 可以在 Android 电视上玩的游戏(无触摸屏)

  • 用内容呈现频道

启动 Android TV 工作室项目

如果你在 Android Studio 中开始一个新的 Android TV 项目,以下是突出的兴趣点:

  • 在清单文件中,这些项目将确保应用也可以在带触摸屏的智能手机上运行,并且包含 Android TV 所需的向后倾斜的用户界面。
<uses-feature
    android:name="android.hardware.touchscreen"
    android:required="false"/>
<uses-feature
    android:name="android.software.leanback"
    android:required="true"/>

  • 仍然在清单文件中,您将看到 start 活动具有如下所示的意图过滤器:
<intent-filter>
  <action android:name=
     "android.intent.action.MAIN"/>
  <category android:name=
     "android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>

这里显示的类别很重要;否则,Android TV 将无法正确识别该应用。该活动还需要有一个android:banner属性,该属性指向 Android TV 用户界面上突出显示的横幅。

  • 在模块的build.gradle文件中,向后倾斜支持库被添加到dependencies部分中。
 implementation 'com.android.support:leanback-
v17:27.1.1'

对于开发,您既可以使用虚拟设备,也可以使用真实设备。虚拟设备通过工具菜单中的 AVD 管理器进行安装。对于真实设备,在设置➤设备➤关于中点击七次内部版本号。然后在“设置”中,转到“首选项”并在“开发人员选项”中启用调试。

Android 电视硬件功能

要了解某个应用是否正在 Android 电视上运行,您可以按如下方式使用UiModeManager:

val isRunnigOnTv =
    (getSystemService(Context.UI_MODE_SERVICE)
         as UiModeManager).currentModeType ==
    Configuration.UI_MODE_TYPE_TELEVISION

此外,可用功能因设备而异。如果您的应用需要某些硬件功能,您可以按如下方式检查可用性:

getPackageManager().
      hasSystemFeature(PackageManager.FEATURE_*)

有关所有可能的特性,请参见PackageManager的 API 文档。

Android 电视设备上的用户输入通常通过 D-pad 控制器进行。为了构建稳定的应用,您应该对 D-pad 控制器可用性的变化做出反应。因此,在AndroidManifest.xml文件中,添加android: configChanges = "keyboard|keyboardHidden|navigation"作为活动属性。然后,应用通过被覆盖的回调函数fun onConfigurationChanged( newConfig : Configuration )获得配置更改的通知。

Android 电视的用户界面开发

对于 Android TV 开发,建议使用倾斜主题。为此,将AndroidManifest.xml文件的<application>元素中的theme属性替换为:

android:theme="@style/Theme.Leanback"

这意味着不使用动作栏,这是有意义的,因为 Android TV 不支持动作栏。此外,活动不得延长AppCompatActivity;而是延长android.support.v4.app.FragmentActivity

Android TV 应用的另一个特点是偶尔可能会发生过扫描。根据像素大小和纵横比,Android TV 可能会裁剪掉部分屏幕。为了避免布局被破坏,建议在主容器上增加 48dp × 27dp 的边距,如下所示:

<RelativeLayout xmlns:android=
      "http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_marginTop="27dp"
    android:layout_marginBottom="27dp"
    android:layout_marginLeft="48dp"
    android:layout_marginRight="48dp">

    <!-- Screen elements ... -->

</RelativeLayout>

此外,对于 Android 电视,建议开发 1920 × 1080 像素。对于其他硬件像素大小,Android 将在必要时自动缩小布局元素。

由于用户无法通过点击式 UI 元素进行导航,而是使用 D-pad 进行导航,因此 Android TV 需要一种在 UI 元素之间切换的替代方法。这可以通过向 UI 元素添加nextFocusUpnextFocusDownnextFocusLeftnextFocusRight属性来轻松实现。参数是导航到元素的 ID 规范,如在@+id/xyzElement中。

对于电视播放组件,向后倾斜库提供了几个方便使用的类和概念,如下所示:

  • 对于媒体浏览器,让你的片段扩展android.support.v17.leanback.app.BrowseSupportFragment。project builder 向导创建了一个废弃的BrowseFragment,但是您可以浏览一下BrowseSupportFragment的 API 文档来学习新的方法。

  • 要在媒体浏览器中呈现的实际媒体项目由卡片视图控制。对应要覆盖的类是android.support.v17.leanback.widget.Presenter

  • 要显示所选媒体项目的细节,请扩展类android.support.v17.leanback.app.DetailsSupportFragment。该向导创建了不推荐使用的DetailFragment,但是它们的用法是相似的,您可以查看 API 文档了解更多细节。

  • 对于显示视频播放的 UI 元素,使用android.support.v17.leanback.app.PlaybackFragmentandroid.support.v17.leanback.app.VideoFragment中的一个。

  • 使用类android.media.session.MediaSession来配置一个“正在播放”的卡。

  • android.media.tv.TvInputService支持将视频流直接呈现到 UI 元素上。调用onTune(...)将开始渲染直接视频流。

  • 如果你的应用需要一个使用几个步骤的指南,例如向用户展示一个购买工作流程,你可以使用类android.support.v17.leanback.app.GuidedStepSupportFragment

  • 要以非交互方式向首次用户展示应用,请使用类android.support.v17.leanback.app.OnboardingSupportFragment

内容搜索的推荐渠道

展示给用户的推荐有两种形式:在 Android 8.0 之前作为推荐行,从 Android 8.0 开始作为推荐通道(API 级)。为了不遗漏用户,您的应用应该在一个交换机中同时提供这两种服务,如下所示:

if (android.os.Build.VERSION.SDK_INT >=
      Build.VERSION_CODES.O) {
  // Recommendation channels API ...
} else {
  // Recommendations row API ...
}

对于 Android 8.0 和更高版本,Android TV 主屏幕在频道列表顶部显示一个全局播放下一行,以及多个频道,每个频道都属于某个应用。“播放下一行”以外的频道不能属于多个应用。每个应用都可以定义一个默认频道,它会自动显示在频道视图中。对于应用可能定义的所有其他频道,用户必须首先批准它们,然后频道才会显示在主屏幕上。

应用需要具有以下权限才能管理频道:

<uses-permission android:name=
    "com.android.providers.tv.permission.READ_EPG_DATA"
    />
<uses-permission android:name=
    "com.android.providers.tv.permission.WRITE_EPG_DATA"
    />

因此,将它们添加到文件AndroidManifest.xml中。

此外,在模块的build.gradle文件中,将以下内容添加到dependencies部分(在一行中):

implementation
      'com.android.support:support-tv-provider:27.1.1'

要创建一个频道,添加一个频道徽标,可能使其成为默认频道,并编写以下内容:

val builder = Channel.Builder()

// Intent to execute when the app link gets tapped.
val appLink = Intent(...).toUri(Intent.URI_INTENT_SCHEME)

// You must use type `TYPE_PREVIEW`
builder.setType(TvContractCompat.Channels.TYPE_PREVIEW)
      .setDisplayName("Channel Name")
      .setAppLinkIntentUri(Uri.parse(appLink))
val channel = builder.build()
val channelUri = contentResolver.insert(
      TvContractCompat.Channels.CONTENT_URI,
      channel.toContentValues())

val channelId = ContentUris.parseId(channelUri)
// Choose one or the other
ChannelLogoUtils.storeChannelLogo(this, channelId,
      /*Uri*/ logoUri)
ChannelLogoUtils.storeChannelLogo(this, channelId,
      /*Bitmap*/ logoBitmap)

// optional, make it the default channel
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
    TvContractCompat.requestChannelBrowsable(this,
          channelId)

要更新或删除通道,您可以使用从通道创建步骤中收集的通道 ID,然后编写以下内容:

// to update:
contentResolver.update(
      TvContractCompat.buildChannelUri(channelId),
      channel.toContentValues(), null, null)

// to delete:
contentResolver.delete(
      TvContractCompat.buildChannelUri(channelId),
      null, null)

要添加程序,请使用以下命令:

val pbuilder = PreviewProgram.Builder()

// Intent to launch when a program gets selected
val progLink = Intent().toUri(Intent.URI_INTENT_SCHEME)

pbuilder.setChannelId(channelId)
    .setType(TvContractCompat.PreviewPrograms.TYPE_CLIP)
    .setTitle("Title")
    .setDescription("Program description")
    .setPosterArtUri(largePosterArtUri)
    .setIntentUri(Uri.parse(progLink))
    .setInternalProviderId(appProgramId)
val previewProgram = pbuilder.build()
val programUri = contentResolver.insert(
      TvContractCompat.PreviewPrograms.CONTENT_URI,
             previewProgram.toContentValues())
val programId = ContentUris.parseId(programUri)

相反,要将一个节目添加到 Play Next 行,您可以使用WatchNextProgram.Builder并编写以下代码:

val wnbuilder = WatchNextProgram.Builder()
val watchNextType = TvContractCompat.
      WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
wnbuilder.setType(
        TvContractCompat.WatchNextPrograms.TYPE_CLIP)
    .setWatchNextType(watchNextType)
    .setLastEngagementTimeUtcMillis(time)
    .setTitle("Title")
    .setDescription("Program description")
    .setPosterArtUri(largePosterArtUri)
    .setIntentUri(Uri.parse(progLink))
    .setInternalProviderId(appProgramId)
val watchNextProgram = wnbuilder.build()
val watchNextProgramUri = contentResolver
    .insert(
         TvContractCompat.WatchNextPrograms.CONTENT_URI,
        watchNextProgram.toContentValues())
val watchnextProgramId =
      ContentUris.parseId(watchNextProgramUri)

对于watchNextType,您可以使用TvContractCompat.WatchNextPrograms中的下列常量之一:

  • WATCH_NEXT_TYPE_CONTINUE:用户在观看内容时停止,可以在这里继续。

  • WATCH_NEXT_TYPE_NEXT:系列中的下一个可用节目可用。

  • 系列中的下一个可用节目是最新可用的。

  • WATCH_NEXT_TYPE_WATCHLIST:用户保存程序时,由系统或 app 插入。

要更新或删除程序,请使用您在程序生成时记忆的程序 ID。

// to update:
contentResolver.update(
      TvContractCompat.
         buildPreviewProgramUri(programId),
      watchNextProgram.toContentValues(), null, null)

// to delete:
contentResolver.delete(
      TvContractCompat.
          buildPreviewProgramUri(programId),
      null, null)

内容搜索的推荐行

对于 Android 7.1(API 级别 25)之前的版本,推荐由一个特殊的推荐行处理。任何更高版本都不能使用建议行。

对于参与 8.0 之前 Android 版本的推荐行的应用,我们首先创建一个新的推荐服务,如下所示:

class UpdateRecommendationsService :
      IntentService("RecommendationService") {
  companion object {
      private val TAG = "UpdateRecommendationsService"
      private val MAX_RECOMMENDATIONS = 3
  }
  override fun onHandleIntent(intent: Intent?) {
      Log.d("LOG", "Updating recommendation cards")

      val recommendations:List<Movie> =
            ArrayList<Movie>()
      // TODO: fill recommendation movie list...

      var count = 0
      val notificationManager =
          getSystemService(Context.NOTIFICATION_SERVICE)
           as NotificationManager
      val notificationId = 42
      for (movie in recommendations) {
          Log.d("LOG", "Recommendation - " +
               movie.title!!)
      val builder = RecommendationBuilder(
          context = applicationContext,
          smallIcon = R.drawable.video_by_icon,
           id = count+1,
          priority = MAX_RECOMMENDATIONS - count,
          title = movie.title ?: "",
          description = "Description",
          image = getBitmapFromURL(
                movie.cardImageUrl ?:""),
          intent = buildPendingIntent(movie))
      val notification = builder.build()
      notificationManager.notify(
            notificationId, notification)
      if (++count >= MAX_RECOMMENDATIONS) {
        break
      }
    }
  }

  private fun getBitmapFromURL(src: String): Bitmap {
      val url = URL(src)
      return (url.openConnection() as HttpURLConnection).
      apply {
          doInput = true
      }.let {
          it.connect()
          BitmapFactory.decodeStream(it.inputStream)
      }
  }

  private fun buildPendingIntent(movie: Movie):
        PendingIntent {
      val detailsIntent =
          Intent(this, DetailsActivity::class.java)
      detailsIntent.putExtra("Movie", movie)

      val stackBuilder = TaskStackBuilder.create(this)
      stackBuilder.addParentStack(
            DetailsActivity::class.java)
      stackBuilder.addNextIntent(detailsIntent)
      // Ensure a unique PendingIntents, otherwise all
      // recommendations end up with the same
      // PendingIntent
      detailsIntent.action = movie.id.toString()

      return stackBuilder.getPendingIntent(
            0, PendingIntent.FLAG_UPDATE_CURRENT)
  }
}

AndroidManifest.xml中的相应条目如下:

<service
    android:name=".UpdateRecommendationsService"
    android:enabled="true" />

代码中的RecommendationBuilder指的是通知生成器周围的包装类。

  class RecommendationBuilder(
    val id:Int = 0,
    val context:Context,
    val title:String,
    val description:String,
    var priority:Int = 0,
    val image: Bitmap,
    val smallIcon: Int = 0,
    val intent: PendingIntent,
    val extras:Bundle? = null
) {
    fun build(): Notification {
      val notification:Notification =
          NotificationCompat.BigPictureStyle(
              NotificationCompat.Builder(context)
                  .setContentTitle(title)
                  .setContentText(description)
                  .setPriority(priority)
                  .setLocalOnly(true)
                  .setOngoing(true)
                  .setColor(...)
                  .setCategory(
                      Notification.CATEGORY_RECOMMENDATION)
                  .setLargeIcon(image)
                  .setSmallIcon(smallIcon)
                  .setContentIntent(intent)
                 .setExtras(extras))
              .build()
        return notification
    }
}

我们需要它,因为创建和传递通知是向系统告知建议的方式。

剩下的是一个组件,它在系统启动时启动,然后定期发送建议。一个例子是使用广播接收器和警报器进行定期更新。

class RecommendationBootup : BroadcastReceiver() {
  companion object {
      private val TAG = "BootupActivity"
      private val INITIAL_DELAY: Long = 5000
  }

  override
  fun onReceive(context: Context, intent: Intent) {
      Log.d(TAG, "BootupActivity initiated")
      if (intent.action!!.endsWith(
            Intent.ACTION_BOOT_COMPLETED)) {
          scheduleRecommendationUpdate(context)
      }
  }

  private
  fun scheduleRecommendationUpdate(context: Context) {
      Log.d(TAG, "Scheduling recommendations update")

      val alarmManager =
            context.getSystemService(
            Context.ALARM_SERVICE) as AlarmManager
      val recommendationIntent = Intent(context,
            UpdateRecommendationsService::class.java)
      val alarmIntent =
            PendingIntent.getService(
            context, 0, recommendationIntent, 0)

      alarmManager.setInexactRepeating(
             AlarmManager.ELAPSED_REALTIME_WAKEUP,
             INITIAL_DELAY,
             AlarmManager.INTERVAL_HALF_HOUR,
             alarmIntent)
  }
}

以下是AndroidManifest.xml中的相应条目:

<receiver android:name=".RecommendationBootup"
          android:enabled="true"
           android:exported="false">
  <intent-filter>
    <action android:name=
        "android.intent.action.BOOT_COMPLETED"/>
  </intent-filter>
</receiver>

为此,您需要以下权限:

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

Android 电视内容搜索

您的 Android TV 应用可能有助于 Android 搜索框架。我们在第八章中描述过。在本节中,我们指出了在电视应用中使用搜索的特点。

表 13-2 描述了对电视搜索建议很重要的搜索项字段;左栏列出了来自SearchManager类的常量名称。您可以在您的数据库中使用它们,或者至少您必须在应用中提供一个映射机制。

表 13-2

电视搜索栏

|

|

描述

| | --- | --- | | SUGGEST_COLUMN_TEXT_1 | 必选。您的内容的名称。 | | SUGGEST_COLUMN_TEXT_2 | 内容的文本描述。 | | SUGGEST_COLUMN_RESULT_CARD_IMAGE | 内容的图片/海报/封面。 | | SUGGEST_COLUMN_CONTENT_TYPE | 必选。媒体的 MIME 类型。 | | SUGGEST_COLUMN_VIDEO_WIDTH | 媒体的宽度。 | | SUGGEST_COLUMN_VIDEO_HEIGHT | 媒体的高度。 | | SUGGEST_COLUMN_PRODUCTION_YEAR | 必选。生产年份。 | | SUGGEST_COLUMN_DURATION | 必需的。以毫秒为单位的持续时间。 |

对于任何其他搜索提供者,在你的应用中为搜索建议创建一个内容提供者。

一旦用户提交搜索对话框,实际上执行一个搜索查询,搜索框架就会用动作SEARCH触发一个意图,因此您可以用适当的意图过滤器编写一个活动,如下所示:

<activity
    android:name=".DetailsActivity"
    android:exported="true">

    <!-- Receives the search request. -->
    <intent-filter>
        <action android:name=
              "android.intent.action.SEARCH" />
    </intent-filter>

    <!-- Points to searchable meta data. -->
    <meta-data android:name="android.app.searchable"
        android:resource="@xml/searchable" />
</activity>

安卓电视游戏

虽然游戏开发最初看起来很吸引人,因为显示器很大,但记住以下几点很重要:

  • 电视总是处于横向模式,所以请确保您的应用善于使用横向模式。

  • 对于多人游戏来说,通常不可能对用户隐藏什么,例如在纸牌游戏中。你可以将电视应用连接到智能手机上运行的配套应用,以解决这一问题。

  • 你的电视游戏应该支持游戏手柄,并且应该突出地告诉用户如何使用它们。在AndroidManifest.xml文件中,你最好声明<uses-feature android:name = "android.hardware.gamepad" android:required = "false"/>。如果你写的是required = "true",你的应用对没有游戏手柄的用户来说是不可卸载的。

Android 电视频道

直播内容的处理,即连续的、基于频道的内容的呈现,由电视输入框架和com.android.tvcom.android.providers.tvandroid.media.tv包中的各种类控制。它主要面向 OEM 制造商,帮助他们将 Android 的电视系统连接到直播流数据。详情请看看这些包的 API 文档或者在你喜欢的搜索引擎中输入Android Building TV Channels

使用 Android Auto 编程

Android Auto 用于将 Android 操作系统转移到兼容的汽车用户界面。到 2018 年,数十家汽车制造商已经或计划在他们的至少一些车型中包含 Android Auto,因此扩展您的应用以包含 Android Auto 功能为您提供了分发应用和改善用户体验的新可能性。作为一种替代的操作模式,Android Auto 应用也可以在智能手机或平板电脑上运行,这使得它可以用于任何类型的汽车,无论是否有兼容的用户界面。

Android Auto 目前仅限于在汽车中使用 Android OS 的以下功能:

  • 播放音频

  • 信息发送

Android Auto 适用于从 Android 5.0 (API 级别 21)开始的设备。此外,您必须在res/xml文件夹中提供一个名为automotive_app_desc.xml的文件,该文件包含以下内容(或其中一些行):

<automotiveApp>
   <uses name="media" />
   <uses name="notification" />
</automotiveApp>

此外,在AndroidManifest.xml中,在<application>元素中添加以下内容:

<meta-data android:name=
      "com.google.android.gms.car.application"
    android:resource=
      "@xml/automotive_app_desc"/>

为 Android Auto 开发

要为 Android Auto 开发应用,请像使用任何其他 Android 应用一样使用 Android Studio。确保您的目标是 API 级别 21 或更高,并且您已经将 v4 支持库添加到模块的build.gradle文件的dependencies部分(在一行上)。

implementation
      'com.android.support:support-v4:27.1.1'

在手机屏幕上测试 Android Auto

要在您的手持设备上测试运行 Android Auto,您必须从 Google Play 安装 Android Auto 应用。然后,在菜单中,点击信息,然后在活动标题上点击十次或更多次(注意,没有反馈!)直到出现启用开发者模式的 toast 通知。现在,点击新的菜单项“开发者设置”并选择“未知来源”选项。重启 Android Auto。在设备上,通过在设置➤关于屏幕中点击内部版本号七次来启用 USB 调试。之后,在设置➤开发者选项中,启用 USB 调试。

为汽车屏幕测试 Android Auto

您可以在桌面主机(DHU)工具中测试自动应用。这在你的手持设备上模拟了一个汽车用户界面。要安装它,首先在设备上启用 USB 调试,方法是在“设置”“➤”“关于”屏幕中点击内部版本号七次。之后,在设置➤开发者选项中,启用 USB 调试。之后,在你的掌上电脑上安装 Android Auto 应用。

在 Android Studio 中,打开工具菜单中的 SDK 管理器,下载并安装 Android Auto 桌面主机仿真器。你可以在 Android SDK ➤ SDK 工具中找到这个选项。

要在 Linux 上运行 DHU,必须安装以下软件包:libsdl2-2.0-0libsdl2-ttf-2.0-0libportaudio2libpng12-0。在 Android Auto 中,启用“测试 Android Auto 的手机屏幕”一节中描述的开发人员选项

除非它已经在运行,否则在 Android Auto 应用的菜单中,选择“启动主机服务器”在“设置”中,点击“已连接的汽车”并确保“向 Android Auto 添加新车”选项已启用。

通过 USB 线将手持设备连接到开发机器,打开终端,进入 Android SDK 文件夹,进入platform-tools文件夹,发出以下命令:

./adb forward tcp:5277 tcp:5277

现在,您可以通过输入以下内容来启动 DHU 工具:

cd <sdk>/extras/google/auto
./desktop-head-unit
# or ./desktop-head-unit -i controller
# for rotary control

DHU 工具现在应该出现在你的开发机器的屏幕上,如图 13-2 所示。

img/463716_1_En_13_Fig2_HTML.jpg

图 13-2

DHU 屏幕

此外,最后一个命令打开了 DHU 工具的外壳,因此可以向它输入和发送命令。下面是一些有趣的 shell 用例:

  • 日间和夜间模式

    在控制台中输入daynight。单击 DHU 屏幕以获得焦点,然后按键盘上的 N 键在白天和夜晚之间切换。

  • 模拟麦克风输入

    输入mic play /path/to/sound/file/file.wav发送声音文件作为模拟麦克风输入。常见的语音命令可以在<sdk>/extras/google/auto/voice/中找到。

  • 睡眠

    输入sleep <N>使系统休眠N秒。

  • 轻点

    输入tap <X> <Y>在一些坐标上模拟一个点击事件(对测试脚本有用)。

如果您已经启用了旋转控制器模式,输入dpad和以下任一选项将模拟一个旋转控制动作:

  • updownleftright:模拟移动。这与箭头键相同。

  • soft leftsoft right:模拟按下侧面按钮(仅在部分设备上)。这与使用 Shift 和箭头键是一样的。

  • click:模拟按压控制器。这与按回车键是一样的。

  • back:模拟按后退键(仅在部分设备上)。这与按退格键是一样的。

  • rotate leftrotate right:模拟控制器旋转。这与按 1 或 2 是一样的。

  • flick leftflick right:模拟控制器的快速旋转。这与按 Shift+1 或 Shift+2 相同。

开发自动音频播放

如果您的应用向 Android Auto 提供音频服务,您可以在文件AndroidManifest.xml中定义一个媒体浏览器服务,如下所示:

<service android:name=".MyMediaBrowserService"
              android:exported="true">
    <intent-filter>
      <action android:name=
          "android.media.browse.MediaBrowserService"/>
    </intent-filter>
</service>

要在<application>中为您的应用额外定义一个通知图标,请编写以下内容:

<meta-data android:name=
      "com.google.android.gms.car.notification.SmallIcon"
    android:resource=
      "@drawable/ic_notification" />

我们将很快关注媒体浏览器服务的实现,但首先我们将讨论一些状态查询方法。首先,如果你的应用需要发现一个 Android Auto 用户是否连接到你的应用,你可以添加一个带有意图过滤器的广播接收器com.google.android.gms.car.media.STATUS。receiver 类的onReceive(...)方法将获得一个由media_connection_status键入的额外值。这个额外的字段的值例如可以读作media_connected以指示连接事件。

此外,应用可以通过使用以下查询来确定它是否在汽车模式下运行:

fun isCarUiMode(c:Context):Boolean {
    val uiModeManager =
          c.getSystemService(Context.UI_MODE_SERVICE) as
          UiModeManager
    return uiModeManager.getCurrentModeType() ==
           Configuration.UI_MODE_TYPE_CAR
  }

现在让我们回到媒体浏览器的实现。最重要的事情是让服务实现抽象类MediaBrowserServiceCompat。在其被覆盖的onCreate(...)方法中,您创建并注册了一个MediaSessionCompat对象。

public void onCreate() {
  super.onCreate()
  ...
  // Start a MediaSession
  val mSession = MediaSessionCompat(
        this, "my session tag")
  val token:MediaSessionCompat.Token =
        mSession.sessionToken

  // Set a callback object to handle play
  /control requests
  mSession.setCallback(
        object : MediaSessionCompat.Callback() {
      // overwrite methods here for
      // playback controls...
  })
  ...
}

在此服务中,您必须实现以下方法:

  • onGetRoot(...)

    这应该会返回内容层次结构的顶层节点。

  • 无子女(-我...。)

    在这里,您返回层次结构中节点的子节点。

为了最大限度地减少汽车司机的注意力分散,你的应用应该能够听到语音命令。要启用“播放 XYZ 或 APP_NAME”等语音命令,只需将以下内容添加到文件AndroidManifest.xml:

<activity>
  <intent-filter>
    <action android:name=
       "android.media.action.MEDIA_PLAY_FROM_SEARCH" />
    <category android:name=
       "android.intent.category.DEFAULT" />
  </intent-filter>
</activity>

这将让框架从您添加到会话的MediaSessionCompat.Callback监听器调用onPlayFromSearch(...)回调。传入的第二个参数可能作为一个Bundle包含额外的搜索选择信息,您可以使用它来过滤您想要在应用中返回的结果。使用MediaStore.EXTRA_*常量之一从Bundle参数中检索值。

要允许回放语音控制动作,如“下一首歌曲”或“恢复音乐”,请将以下内容作为标志添加到媒体会话对象:

mSession.setFlags(
      MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or
      MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS)

在汽车上开发信息

您的汽车应用可能有助于 Android 自动消息传递。更准确地说,您可以执行以下一项或多项操作:

  • 向 Android Auto 发布通知。通知由消息本身和用于对消息进行分组的会话 ID 组成。您不必自己进行分组,但是为通知分配一个会话 ID 是很重要的,因此框架可以让用户知道通知属于与一个专用通信伙伴的会话。

  • 当用户听到消息时,框架将触发一个“消息阅读”意图,你的应用可以捕捉到。

  • 用户可以使用自动框架发送回复。这伴随着另一个由框架触发的“消息回复”意图,并被你的应用捕获。

为了捕捉“消息读取”和“消息回复”事件,您按照AndroidManifest.xml中的以下条目编写接收者:

<application>
  ...
  <receiver android:name=".MyMessageReadReceiver"
            android:exported="false">
      <intent-filter>
        <action android:name=
          "com.myapp.auto.MY_ACTION_MESSAGE_READ"/>
      </intent-filter>
  </receiver>

  <receiver android:name=".MyMessageReplyReceiver"
        android:exported="false">
      <intent-filter>
        <action android:name=
          "com.myapp.auto.MY_ACTION_MESSAGE_REPLY"/>
      </intent-filter>
  </receiver>
  ...
</application>

您必须告诉 Auto 您希望接收此类事件。为此,您需要准备适当的PendingIntent对象,如下所示:

val msgReadIntent = Intent().apply {
  addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES)
  setAction("com.myapp.auto.MY_ACTION_MESSAGE_READ")
  putExtra("conversation_id", thisConversationId)
  setPackage("com.myapp.auto")
}.let {
  PendingIntent.getBroadcast(applicationContext,
      thisConversationId,
      it,
      PendingIntent.FLAG_UPDATE_CURRENT)
}

val msgReplyIntent = Intent().apply {
  addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES)
  setAction("com.myapp.auto.MY_ACTION_MESSAGE_REPLY")
  putExtra("conversation_id", thisConversationId)
  setPackage("com.myapp.auto")
}.let {
  PendingIntent.getBroadcast(applicationContext,
      thisConversationId,
      it,
      PendingIntent.FLAG_UPDATE_CURRENT)
}

这里,com.myapp.auto是与您的应用相关联的包名。你必须适当地替换它。

为了进一步处理与 Android Auto 的交互,我们需要一个可以如下生成的UnreadConversation对象:

// Build a RemoteInput for receiving voice input
// in a Car Notification
val remoteInput =
    RemoteInput.Builder(MY_VOICE_REPLY_KEY)
    .setLabel("The label")
    .build()

val unreadConvBuilder =
  UnreadConversation.Builder(conversationName)
      .setReadPendingIntent(msgReadIntent)
      .setReplyAction(msgReplyIntent, remoteInput)

这里,conversationName是显示给自动用户的对话的名称。如果对话涉及多个用户,这也可以是逗号分隔的标识符列表。

构建器还没有准备好。我们首先添加如下消息:

unreadConvBuilder.addMessage(messageString)
      .setLatestTimestamp(currentTimestamp)

接下来我们准备一个NotificationCompat.Builder对象。我们向这个构建器添加前面的unreadConvBuilder构建器,从系统中获取一个NotificationManager,最后发送消息。

val notificationBuilder =
    NotificationCompat.Builder(applicationContext)
      .setSmallIcon(smallIconResourceID)
      .setLargeIcon(largeIconBitmap)

notificationBuilder.extend(CarExtender()
  .setUnreadConversation(unreadConvBuilder.build())

NotificationManagerCompat.from(/*context*/this).run {
    notify(notificationTag,
           notificationId,
            notificationBuilder.build())

剩下的就是处理“消息阅读”和“消息回复”事件,如果你注册了它们的话。为此,您编写相应的BroadcastReceiver类,如AndroidManifest.xml中的条目所示。请注意,对于“消息回复”操作,您需要使用某种方式来获取消息。

val remoteInput =
      RemoteInput.getResultsFromIntent(intent)?.let {
           it.getCharSequence(MY_VOICE_REPLY_KEY)
      } ?: ""

播放和录制声音

在 Android 中播放声音意味着一件或两件事,或者两件都意味着:

  • 简短的声音片段:您通常会播放它们作为对用户界面操作的反馈,比如按下按钮或在编辑字段中输入内容。另一个用例是游戏,其中某些事件可以映射到短音频片段。特别是对于用户界面的反应,确保你不会惹恼用户,并提供一个随时静音音频输出的可能性。

  • 音乐播放:您想要播放持续时间超过几秒钟的音乐。

对于简短的音频片段,您使用一个SoundPool;对于音乐作品,你用一个MediaPlayer。我们将在下面的章节中讨论它们和录音。

简短的声音片段

对于简短的声音片段,您可以使用SoundPool并在初始化期间预加载声音。使用SoundPool.load(...)方法之一加载声音片段后,您不能立即使用它们。相反,你必须等到所有的声音都加载完毕。建议的方法是不要等待一段时间,因为你经常可以在一些博客中读到。取而代之的是,监听声音加载事件并计算完成的片段。您可以让自定义类来完成这项工作,如下所示:

class SoundLoadManager(val ctx:Context) {
  var scheduled = 0
  var loaded = 0
  val sndPool:SoundPool
  val soundPoolMap = mutableMapOf<Int,Int>()
  init {
      sndPool =
          if (Build.VERSION.SDK_INT >=
                 Build.VERSION_CODES.LOLLIPOP) {
            SoundPool.Builder()
               .setMaxStreams(4)
               .setAudioAttributes(
                  AudioAttributes.Builder()
                .setUsage(
                   AudioAttributes.USAGE_MEDIA)
                .setContentType(
                    AudioAttributes.CONTENT_TYPE_MUSIC)
                .build()
            ).build()
         } else {
             SoundPool(4,
                  AudioManager.STREAM_MUSIC,
                  100)
         }
     sndPool.setOnLoadCompleteListener({
         sndPool, sampleId, status ->
         if(status != 0) {
             Log.e("LOG",
                   "Sound could not be loaded")
         } else {
             Log.i("LOG", "Loaded sample " +
                   sampleId + ", status = " +
                   status)
         }
         loaded++
     })
  }
  fun load(resourceId:Int) {
      scheduled++
      soundPoolMap[resourceId] =
            sndPool.load(ctx, resourceId, 1)
  }

  fun allLoaded() = scheduled == loaded

  fun play(rsrcId: Int, loop: Boolean):Int {
      return soundPoolMap[rsrcId]?.run {
          val audioManager = ctx.getSystemService(
                Context.AUDIO_SERVICE) as AudioManager
          val curVolume = audioManager.
                 getStreamVolume(
                 AudioManager.STREAM_MUSIC)
          val maxVolume = audioManager.
                 getStreamMaxVolume(
                 AudioManager.STREAM_MUSIC)
          val leftVolume = 1f * curVolume / maxVolume
          val rightVolume = 1f * curVolume / maxVolume
          val priority = 1
          val noLoop = if(loop) -1 else 0
          val normalPlaybackRate = 1f
           sndPool.play(this, leftVolume, rightVolume,
                priority, noLoop, normalPlaybackRate)
      } ?: -1
  }
}

请注意该类的以下内容:

  • 加载并保存SoundPool的实例。该构造函数已被弃用,这就是我们根据 Android API 级别使用不同的初始化方法的原因。此处显示的参数可根据您的需要进行调整;请参见SoundPoolSoundPool.BuilderAudioAttributes.Builder的 API 文档。

  • 提供一个将资源 ID 作为参数的load()方法。例如,这可能是一个位于res/raw文件夹中的 WAV 文件。

  • 提供了一个allLoaded()方法,你可以用它来检查是否所有的声音都被加载了。

  • 提供了一个play()方法,您可以使用它来播放加载的声音。如果声音还没有加载,这将不起任何作用。如果声音被实际播放,这将返回流 ID,否则返回-1

若要使用类,请创建一个具有实例的字段。例如,在初始化时,在活动的onCreate(...)方法中,加载声音并调用play()开始播放。

...
lateinit var soundLoadManager:SoundLoadManager
...
override
fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    ...
    soundLoadManager = SoundLoadManager(this)
    with(soundLoadManager) {
      load(R.raw.click)
      // more ...
    }
}

fun go(v: View) {
    Log.e("LOG", "All sounds loaded = " +
         soundLoadManager.allLoaded())
    val strmId = soundLoadManager.play(
           R.raw.click, false)
    Log.e("LOG", "Stream ID = " + strmId.toString())
}

SoundPool类也允许停止和恢复声音。如果需要的话,您可以适当地扩展SoundLoadManager类来考虑这个问题。

播放媒体

注册和播放任意长度和任意来源的音乐剪辑只需要这个类MediaPlayer。它是一个状态引擎,因此不太容易处理,但是我们首先讨论操作媒体播放器可能需要的权限。

  • 如果您的应用需要播放来自互联网的媒体,您必须通过向文件AndroidManifest.xml添加以下内容来允许互联网访问:
<uses-permission android:name=
    "android.permission.INTERNET" />

  • 如果您想要防止播放被进入睡眠状态的设备中断,您需要获取唤醒锁。我们稍后会对此进行更多的讨论,但是为了使这一切成为可能,您需要向AndroidManifest.xml添加以下权限:
<uses-permission android:name=
    "android.permission.WAKE_LOCK" />

要进一步了解如何获得代码中的权限,请参考第七章。

设置好必要的权限后,我们现在可以处理MediaPlayer类了。如前所述,它的一个实例创建了一个状态机,从一个状态到另一个状态的转换对应于不同的回放状态。更详细地说,对象可以处于以下状态之一:

  • 空闲

    一旦被默认的构造函数构造或者在reset()之后,播放器就处于空闲状态。

  • 已初始化

    一旦通过setDataSource(...)设置了数据源,播放器就处于初始化的状态。除非你首先使用一个reset(),否则再次调用setDataSource(...)会导致错误。

  • 准备完毕

    准备转换准备一些资源和数据流用于回放。因为这可能需要一些时间,特别是对于来自互联网上的数据源的流资源,有两种可能来参与该转换:prepare()方法执行该步骤并阻塞程序流直到它完成,而prepareAsync()方法将准备发送到后台。在后一种情况下,您必须通过setOnPreparedListener(...)注册一个监听器,以查明准备步骤实际上是何时完成的。初始化后必须做好启动前的准备工作,在一个stop()方法后必须再做一次才能再次启动回放。

  • 开始

    准备成功后,可以通过调用start()开始播放。

  • 暂停

    start()之后,您可以通过调用pause来暂停播放。再次调用start将在当前播放位置恢复播放。

  • 停止了

    您可以通过调用stop()来停止播放,无论是正在播放还是暂停播放。一旦停止,就不允许再次开始,除非先重复准备步骤。

  • 已完成

    一旦回放完成且无循环活动,则进入完成状态。你可以从这里停下来,也可以重新开始。

注意,各种静态create(...)工厂方法收集了几个转换。有关详细信息,请参见 API 文档。

举个例子,一个基本的播放器 UI 界面,用于播放来自assets文件夹中的音乐文件,利用同步准备,带有一个开始/暂停按钮和一个停止按钮,如下所示:

var mPlayer: MediaPlayer? = null
fun btnText(playing:Boolean) {
    startBtn.text = if(playing) "Pause" else "Play"
}
fun goStart(v:View) {
    mPlayer = mPlayer?.run {
        btnText(!isPlaying)
        if(isPlaying)
            pause()
        else
            start()
        this
    } ?: MediaPlayer().apply {
        setOnCompletionListener {
            btnText(false)
        release()
        mPlayer = null
    }
    val fd: AssetFileDescriptor =
          assets.openFd("tune1.mp3")
    setDataSource(fd.fileDescriptor)
    prepare() // synchronous
    start()
    btnText(true)
  }
}

fun goStop(v:View) {
    mPlayer?.run {
        stop()
        prepare()
        btnText(false)
    }
}

代码基本上是不言自明的。一旦按钮被按下,就会调用goStart()goStop()方法,而btnText(...)用于指示状态变化。这里使用的构造可能看起来很奇怪,但是它所做的就是:如果mPlayer对象不是null,那么执行(A),最后对它自己执行一个 void 赋值。否则,构造它,然后对它应用(B)

mPlayer = mPlayer?.run {
    (A)
    this
} ?: MediaPlayer().apply {
    (B)
}

为了让这个例子工作,你的布局中必须有 id 为startBtnstopBtn的按钮,通过android:onclick="goStop"android:onclick="goStart"连接它们,并且在你的assets/文件夹中有一个名为tune1.mp3的文件。该示例在“播放”和“暂停”标签之间切换按钮文本;当然,你也可以在这里使用ImageButton视图,按下后改变图标。

要使用任何其他数据源,包括来自互联网的在线流,请应用各种setDataSource(...)替代方法之一或使用静态create(...)方法之一。为了监控各种状态转换,通过setOn...Listener(...)添加适当的监听器。进一步建议,一旦你使用完一个MediaPlayer对象,立即调用它的release()来释放不再使用的系统资源。

一些音乐的回放也可以在后台处理,例如使用服务而不是活动。在这种情况下,如果您希望避免设备因决定进入睡眠模式而中断播放,您可以通过以下方式获取唤醒锁,以避免 CPU 进入睡眠状态:

mPlayer.setWakeMode(applicationContext,
      PowerManager.PARTIAL_WAKE_LOCK)

这避免了网络连接被中断:

val wifiLock = (applicationContext.getSystemService(
 Context.WIFI_SERVICE) as WifiManager)
    .createWifiLock(WifiManager.WIFI_MODE_FULL, "
 myWifilock")
.run {
  acquire()
  this
}
... later:
wifiLock.release()

录制音频

为了录制音频,您可以使用类MediaRecorder。使用它相当简单,如下所示:

val mRecorder = MediaRecorder().apply {
   setAudioSource(MediaRecorder.AudioSource.MIC)
   setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
   setOutputFile(mFileName)
   setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
}
mRecorder.prepare()
mRecorder.start()

... later:
mRecorder.stop()

关于输入、媒体格式和输出的其他选项,请参见类MediaRecorder的 API 文档。

使用相机

向用户展示事物的应用一直是计算机的主要应用领域。先是文字,后来是图片,甚至后来是电影。只有在过去的几十年里,相反的,让用户展示东西,获得了相当大的关注。随着手持设备配备质量越来越高的相机,对能够处理相机数据的应用的需求已经出现。Android 在这里帮助很大;一个应用可以告诉 Android 操作系统拍照或录制电影并将其保存在某个地方,或者它可以完全控制相机硬件并持续监控相机数据,并根据需要更改变焦、曝光和对焦。

我们将在接下来的章节中讨论所有这些内容。如果您需要这里没有描述的特性或设置,API 文档可以作为进一步研究的起点。

拍照

与相机硬件通信的高级方法是这个命令的 IT 对应物:“拍照并保存在我告诉你的某个地方。”为了实现这一点,假设手持设备实际上有一个摄像头,并且您有使用它的权限,您调用某个 intent 来告诉路径名在哪里保存图像。在意图结果检索时,您可以访问图像数据,既可以直接访问低分辨率缩略图,也可以访问所请求位置的完整图像数据。

我们首先告诉 Android 我们的应用需要一个摄像头。这是通过文件AndroidManifest.xml中的<uses-feature>元素实现的。

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

在您的应用中,您将进行运行时检查并采取相应的行动。

if (!packageManager.hasSystemFeature(
      PackageManager.FEATURE_CAMERA)) {
    ...
 }

要声明必要的权限,您需要在清单文件AndroidManifrest.xml<manifest>元素中编写。

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

检查该许可,如果获得许可,请参见第七章。如果你想将图片保存到一个公开可用的商店,以便其他应用可以看到它,你还需要以同样的方式声明和获取权限android.permission.WRITE_EXTERNAL_STORAGE。相反,要将图片数据保存到应用的私有空间,您需要声明一个稍微不同的权限,如下所示:

<uses-permission android:name=
      "android.permission.WRITE_EXTERNAL_STORAGE"
      android:maxSdkVersion="18"/>

这个声明只有在 Android 4.4 (API 级别 18)以下才有必要。

我们需要做一些额外的工作来访问图像数据存储。除了我们刚刚描述的许可之外,我们还需要在内容供应器安全级别上访问存储。这意味着,在AndroidManifest.xml<application>元素中,添加以下内容:

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

在文件res/xml/file_paths.xml中,写下以下内容:

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

path 属性中的值取决于我们是将图片保存在公共存储中还是应用的私有数据空间中。

  • 如果您想将图像保存到应用的私有数据空间,请使用Android/data/com.example. package.name/files/Pictures

  • 如果您想将图像保存到公共数据空间,使用Pictures

注意

如果您使用该应用的私人数据空间,则在卸载该应用时,所有图片都将被删除。

要启动系统的摄像头,首先创建一个空文件来写入拍摄的照片,然后创建并启动一个意图,如下所示:

val REQUEST_TAKE_PHOTO = 42
var photoFile:File? = null
fun dispatchTakePictureIntent() {
  fun createImageFile():File {
    val timeStamp =
          SimpleDateFormat("yyyyMMdd_HHmmss_SSS",
          Locale.US).format(Date())
    val imageFileName = "JPEG_" + timeStamp + "_"

    val storageDir =
          Environment.getExternalStoragePublicDirectory(
          Environment.DIRECTORY_PICTURES)
    // To instead take the App's private space:
    // val storageDir =
    // getExternalFilesDir(
    // Environment.DIRECTORY_PICTURES)
    val image = File.createTempFile(
          imageFileName,
          ".jpg",
          storageDir)
          return image
}

val takePictureIntent =
      Intent(MediaStore.ACTION_IMAGE_CAPTURE)
val canHandleIntent = takePictureIntent.
      resolveActivity(packageManager) != null
if (canHandleIntent) {
      photoFile = createImageFile()
      Log.e("LOG","Photo output File: ${photoFile}")
      val photoURI = FileProvider.getUriForFile(this,
            "com.example.autho.fileprovider",
            photoFile!!)
      Log.e("LOG","Photo output URI: ${photoURI}")
      takePictureIntent.putExtra(
            MediaStore.EXTRA_OUTPUT, photoURI)
      startActivityForResult(takePictureIntent,
            REQUEST_TAKE_PHOTO)
    }
}
dispatchTakePictureIntent()

注意,FileProvider.getUriForFile()中的第二个参数指定了权限,因此也必须出现在文件AndroidManifest.xml<provider>元素中,如前所示。

照片拍摄完成后,应用的onActivityResult()可用于获取图像数据。

override
fun onActivityResult(requestCode: Int, resultCode: Int,
      data: Intent) {
  if ((requestCode and 0xFFFF) == REQUEST_TAKE_PHOTO
        && resultCode == Activity.RESULT_OK) {
    val bmOptions = BitmapFactory.Options()
    BitmapFactory.decodeFile(
          photoFile?.getAbsolutePath(), bmOptions)?.run {
      imgView.setImageBitmap(this)
    }
  }
}

这里,imgView指向 UI 布局内部的一个ImageView元素。

警告

尽管 API 文档中暗示了这一点,但返回的 intent 并不可靠地在其data字段中包含缩略图。有些设备可以做到这一点,但有些则不行。

因为我们使用photoFile字段来传输图像文件的名称,所以我们必须注意它能够在活动重启后继续存在。要确保它被持久化,请编写以下代码:

override
fun onSaveInstanceState(outState: Bundle?) {
  super.onSaveInstanceState(outState)
  photoFile?.run{
    outState?.putString("imgFile", absolutePath)
  }
}

并在onCreate(...)内添加:

savedInstanceState?.run {
  photoFile = getString("imgFile")?.let {File(it)}
}

只有当您使用公共可用空间来存储图片时,您才能将图像公布到系统的媒体扫描仪。为此,请编写以下内容:

val mediaScanIntent =
      Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
val contentUri = Uri.fromFile(photoFile)
mediaScanIntent.setData(contentUri)
sendBroadcast(mediaScanIntent)

录制视频

如前所述,使用该系统的应用录制视频与拍照并无实质区别。本节的其余部分假设您已经完成了该部分。

首先,我们需要文件res/xml/file_paths.xml中的一个不同的条目。由于我们现在正在处理视频部分,请编写以下内容:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android=
           "http://schemas.android.com/apk/res/android">
    <external-path name="my_videos"
                   path="Android/data/de.pspaeth.camera/
   files/Movies" />
</paths>

要将视频保存在应用的私有数据空间中,或者改为使用所有应用都可用的公共数据空间,请使用:

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

然后,要告诉 Android 操作系统开始录制视频并将数据保存到我们选择的文件中,请编写以下内容:

var videoFile:File? = null
val REQUEST_VIDEO_CAPTURE = 43

fun dispatchRecordVideoIntent() {
  fun createVideoFile(): File {
    val timeStamp =
          SimpleDateFormat("yyyyMMdd_HHmmss_SSS",
          Locale.US).format(Date())
    val imageFileName = "MP4_" + timeStamp + "_"
    val storageDir =
          Environment.getExternalStoragePublicDirectory(
          Environment.DIRECTORY_MOVIES)
    // To instead tke the App's private space:
    // val storageDir = getExternalFilesDir(
    // Environment.DIRECTORY_MOVIES)
    val image = File.createTempFile(
          imageFileName,
          ".mp4",
          storageDir)
    return image
  }

  val takeVideoIntent =
        Intent(MediaStore.ACTION_VIDEO_CAPTURE)
  if (takeVideoIntent.resolveActivity(packageManager)
        != null) {
    videoFile = createVideoFile()
    val videoURI = FileProvider.getUriForFile(this,
          "com.example.autho.fileprovider",
          videoFile!!)
    Log.e("LOG","Video output URI: ${videoURI}")
    takeVideoIntent.putExtra(MediaStore.EXTRA_OUTPUT,
          videoURI)
    startActivityForResult(
          takeVideoIntent, REQUEST_VIDEO_CAPTURE)
    }
}
dispatchRecordVideoIntent()

为了在录制完成后最终获取视频数据,将以下内容添加到onActivityResult(...):

if((requestCode == REQUEST_VIDEO_CAPTURE and 0xFFFF) &&
      resultCode == Activity.RESULT_OK) {
    videoView.setVideoPath(videoFile!!.absolutePath)
    videoView.start()
}

这里,videoView指向布局文件中的一个VideoView

此外,因为我们需要确保videoFile成员在活动重启后仍然存在,所以将它添加到onSaveInstanceState(...)onCreate()中,如前面针对photoFile字段所示。

编写自己的相机应用

使用意图来告诉 Android 操作系统为我们拍照或录制视频对于许多用例来说可能是好的。但是,一旦您需要对摄像机或 GUI 进行更多的控制,您就需要使用摄像机 API 编写自己的摄像机访问代码。在这一节中,我将向您展示一个可以做到这两点的应用——向您展示预览并让您拍摄静态图像。

注意

在本书中,我们主要考虑了 Android 或更高版本。在当前部分,我们稍微偏离了这个策略。API 21 级(Android 5.0)之前被弃用的相机 API 与 21 级以来的新相机 API 有很大不同。这就是我们选择使用新 API 的原因,到 2018 年年中,该 API 将覆盖 85%或更多的设备。对于旧的 API,请参见在线文档。

我们从三个实用程序类开始。第一个类是一个TextureView的扩展。我们使用一个TextureView,因为它允许相机硬件和屏幕之间更快速的连接,我们扩展了它,使它更好地适应相机的固定比例输出。清单内容如下:

/**
 * A custom TextureView which is able to automatically
 * crop its size according to an aspect ratio set
 */
class AutoFitTextureView : TextureView {
    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) :
         super(context, attrs)
    constructor(context: Context, attrs: AttributeSet?,
         attributeSetId: Int) :
         super(context, attrs, attributeSetId)

    var mRatioWidth = 0
    var mRatioHeight = 0

    /**
     * Sets the aspect ratio for this view. The size of
     * the view will be measured based on the ratio
     * calculated from the parameters. Note that the
     * actual sizes of parameters don't matter, that
     * is, calling setAspectRatio(2, 3) and
     * setAspectRatio(4, 6) make the same result.
     *
     * @param width Relative horizontal size
     * @param height Relative vertical size
     */
    fun setAspectRatio(width:Int, height:Int) {
        if (width < 0 || height < 0) {
            throw IllegalArgumentException(
                  "Size cannot be negative.");
        }
        mRatioWidth = width;
        mRatioHeight = height;
        requestLayout()
    }

    override
    fun onMeasure(widthMeasureSpec:Int,
          heightMeasureSpec:Int) {
        super.onMeasure(
              widthMeasureSpec, heightMeasureSpec)
        val width = MeasureSpec.getSize(widthMeasureSpec)
        val height = MeasureSpec.getSize(heightMeasureSpec)
        if (0 == mRatioWidth || 0 == mRatioHeight) {
            setMeasuredDimension(width, height)
        } else {
            val ratio = 1.0 * mRatioWidth / mRatioHeight
            if (width < height * ratio) {
            setMeasuredDimension(
                  width, (width / ratio).toInt())
        } else {
            setMeasuredDimension(
                 (height * ratio).toInt(), height)
        }
    }
  }
}

下一个实用程序类在系统中查询背面摄像头,一旦找到就存储其特征。其内容如下:

/**
 * Find a backface camera
 */
class BackfaceCamera(context:Context) {
  var cameraId: String? = null
  var characteristics: CameraCharacteristics? = null

  init {
      val manager = context.getSystemService(
            Context.CAMERA_SERVICE) as CameraManager
      try {
            manager.cameraIdList.find {
                manager.getCameraCharacteristics(it).
                get(CameraCharacteristics.LENS_FACING) ==
                    CameraCharacteristics.LENS_FACING_BACK
            }.run {
                cameraId = this
                characteristics = manager.
                      getCameraCharacteristics(this)
            }
        } catch (e: CameraAccessException) {
            Log.e("LOG", "Cannot access camera", e)
        }
  }
}

第三个实用程序类执行一些计算,帮助我们适当地将相机输出尺寸映射到纹理视图尺寸。其内容如下:

/**
 * Calculates and holds preview dimensions
 */
class PreviewDimension {

  companion object {
      val LOG_KEY = "PreviewDimension"

      // Max preview width guaranteed by Camera2 API
      val MAX_PREVIEW_WIDTH = 1920

      // Max preview height guaranteed by Camera2 API
      val MAX_PREVIEW_HEIGHT = 1080

      val ORIENTATIONS = SparseIntArray().apply {
          append(Surface.ROTATION_0, 90);
          append(Surface.ROTATION_90, 0);
          append(Surface.ROTATION_180, 270);
          append(Surface.ROTATION_270, 180);
}

作为配套功能,我们需要一种方法,在给定相机支持的大小的情况下,选择最小的一个,该最小的一个至少与相应的纹理视图大小一样大,最多与相应的最大大小一样大,并且其纵横比与指定值匹配。如果这样的尺寸不存在,它选择最大的一个,该最大的一个至多与相应的最大尺寸一样大,并且其纵横比与指定值匹配。

/**
 * Calculate the optimal size.
 *
 * @param choices           The list of sizes
 * that the camera supports for the intended
 * output class
 * @param textureViewWidth  The width of the
 * texture view relative to sensor coordinate
 * @param textureViewHeight The height of the
 * texture view relative to sensor coordinate
 * @param maxWidth          The maximum width
 * that can be chosen
 * @param maxHeight         The maximum height
 * that can be chosen
 * @param aspectRatio       The aspect ratio
 * @return The optimal size, or an arbitrary one
 * if none were big enough
 */
fun chooseOptimalSize(choices: Array<Size>?,
      textureViewWidth: Int,
      textureViewHeight: Int,
      maxWidth: Int, maxHeight: Int,
      aspectRatio: Size): Size {

    // Collect the supported resolutions that are
    // at least as big as the preview Surface
    val bigEnough = ArrayList<Size>()
    // Collect the supported resolutions that are
    // smaller than the preview Surface
    val notBigEnough = ArrayList<Size>()
    val w = aspectRatio.width
    val h = aspectRatio.height
    choices?.forEach { option ->
      if (option.width <= maxWidth &&
            option.height <= maxHeight &&
            option.height ==
                  option.width * h / w) {
          if (option.width >= textureViewWidth
              && option.height >=
                 textureViewHeight) {
              bigEnough.add(option)
          } else {
              notBigEnough.add(option)
          }
      }
    }

    // Pick the smallest of those big enough. If
    // there is no one big enough, pick the
    // largest of those not big enough.
    if (bigEnough.size > 0) {
        return Collections.min(bigEnough,
              CompareSizesByArea())
    } else if (notBigEnough.size > 0) {
        return Collections.max(notBigEnough,
              CompareSizesByArea())
    } else {
        Log.e(LOG_KEY,
              "Couldn't find any suitable size")
                 return Size(textureViewWidth,
                       textureViewHeight)
            }
        }

       /**
         * Compares two sizes based on their areas.
         */
        class CompareSizesByArea : Comparator<Size> {
            override
            fun compare(lhs: Size, rhs: Size): Int {
                // We cast here to ensure the
                // multiplications won't overflow
                return Long.signum(lhs.width.toLong() *
                      lhs.height -
                      rhs.width.toLong() * rhs.height)
            }
        }
    }

    internal var rotatedPreviewWidth: Int = 0
    internal var rotatedPreviewHeight: Int = 0
    internal var maxPreviewWidth: Int = 0
    internal var maxPreviewHeight: Int = 0
    internal var sensorOrientation: Int = 0
    internal var previewSize: Size? = null

我们需要一种方法来计算预览维度,包括传感器方向。方法calcPreviewDimension()就是这么做的。

fun calcPreviewDimension(width: Int, height: Int,
      activity: Activity, bc: BackfaceCamera) {
    // Find out if we need to swap dimension to get
    // the preview size relative to sensor coordinate.
    val displayRotation =
        activity.windowManager.defaultDisplay.rotation

    sensorOrientation = bc.characteristics!!.
        get(CameraCharacteristics.SENSOR_ORIENTATION)
    var swappedDimensions = false
    when (displayRotation) {
        Surface.ROTATION_0, Surface.ROTATION_180 ->
            if (sensorOrientation == 90 ||
                sensorOrientation == 270) {
            swappedDimensions = true
        }
        Surface.ROTATION_90, Surface.ROTATION_270 ->
            if (sensorOrientation == 0 ||
                sensorOrientation == 180) {
            swappedDimensions = true
        }
        else -> Log.e("LOG",
            "Display rotation is invalid: " +
            displayRotation)
    }

    val displaySize = Point()
    activity.windowManager.defaultDisplay.
        getSize(displaySize)
    rotatedPreviewWidth = width
    rotatedPreviewHeight = height
    maxPreviewWidth = displaySize.x
    maxPreviewHeight = displaySize.y

    if (swappedDimensions) {
        rotatedPreviewWidth = height
        rotatedPreviewHeight = width
        maxPreviewWidth = displaySize.y
        maxPreviewHeight = displaySize.x
    }

    if (maxPreviewWidth > MAX_PREVIEW_WIDTH) {
        maxPreviewWidth = MAX_PREVIEW_WIDTH
    }

    if (maxPreviewHeight > MAX_PREVIEW_HEIGHT) {
        maxPreviewHeight = MAX_PREVIEW_HEIGHT
    }
}

/**
 * Retrieves the JPEG orientation from the specified
 * screen rotation.
 *
 * @param rotation The screen rotation.
 * @return The JPEG orientation
 *       (one of 0, 90, 270, and 360)
 */
fun getOrientation(rotation: Int): Int {
    // Sensor orientation is 90 for most devices, or
    // 270 for some devices (eg. Nexus 5X). We have
    // to take that into account and rotate JPEG
    // properly. For devices with orientation of 90,
    // we simply return our mapping from ORIENTATIONS.
    // For devices with orientation of 270, we need
    // to rotate the JPEG 180 degrees.
    return (ORIENTATIONS.get(rotation) +
          sensorOrientation + 270) % 360
}

为了实现正确的预览图像显示,我们使用了getTransformationMatrix()方法,如下所示:

fun getTransformationMatrix(activity: Activity,
      viewWidth: Int, viewHeight: Int): Matrix {
    val matrix = Matrix()
    val rotation = activity.windowManager.
          defaultDisplay.rotation
    val viewRect = RectF(0f, 0f,
          viewWidth.toFloat(), viewHeight.toFloat())
    val bufferRect = RectF(0f, 0f,
          previewSize!!.height.toFloat(),
          previewSize!!.width.toFloat())
    val centerX = viewRect.centerX()
    val centerY = viewRect.centerY()
    if (Surface.ROTATION_90 == rotation
          || Surface.ROTATION_270 == rotation) {
        bufferRect.offset(
              centerX - bufferRect.centerX(),
              centerY - bufferRect.centerY())
        matrix.setRectToRect(viewRect, bufferRect,
              Matrix.ScaleToFit.FILL)
        val scale = Math.max(
          viewHeight.toFloat() / previewSize!!.height,
          viewWidth.toFloat() / previewSize!!.width)
        matrix.postScale(
              scale, scale, centerX, centerY)
        matrix.postRotate(
             (90 * (rotation - 2)).toFloat(),
             centerX, centerY)
    } else if (Surface.ROTATION_180 == rotation) {
        matrix.postRotate(180f, centerX, centerY)
    }
    return matrix
  }
}

与前面的部分一样,我们需要确保能够获得必要的权限。为此,在AndroidManifest.xml中添加以下内容:

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

接下来,我们编写一个活动,该活动检查并可能获取必要的权限,打开一个Camera对象,稍后我们将定义该对象的类,添加一个静态图像捕获按钮和一个捕获的静态图像消费者回调,并处理一个转换矩阵以使TextureView对象显示正确大小的预览图片。它看起来会像这样:

class MainActivity : AppCompatActivity() {
  companion object {
      val LOG_KEY = "main"
      val PERM_REQUEST_CAMERA = 642
  }

  lateinit var previewDim:PreviewDimension
  lateinit var camera:Camera

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

      val permission1 =
            ContextCompat.checkSelfPermission(
            this, Manifest.permission.CAMERA)
      if (permission1 !=
            PackageManager.PERMISSION_GRANTED) {
          ActivityCompat.requestPermissions(this,
                arrayOf(Manifest.permission.CAMERA),
                PERM_REQUEST_CAMERA)
  }else{
      start()
  }
}

override fun onDestroy() {
    super.onDestroy()
    camera.close()
}

fun go(v: View) {
    camera.takePicture()
}

方法start()用于正确处理相机对象并设置预览画布。注意,当屏幕关闭再打开时,SurfaceTexture已经可用,onSurfaceTextureAvailable不会被调用。在这种情况下,我们可以打开一个摄像头,从这里开始预览。否则,我们在SurfaceTextureListener中等到表面准备好。

private fun start() {
  previewDim = PreviewDimension()
  camera = Camera(
        this, previewDim, cameraTexture).apply {
      addPreviewSizeListener { w,h ->
          Log.e(LOG_KEY,
              "Preview size by PreviewSizeListener:
              ${w} ${h}")
          cameraTexture.setAspectRatio(w,h)
      }
      addStillImageConsumer(::dataArrived)
  }

  // Correctly handle the screen turned off and
  // turned back on.
  if (cameraTexture.isAvailable()) {
      camera.openCamera(cameraTexture.width,
            cameraTexture.height)
      configureTransform(cameraTexture.width,
            cameraTexture.height)
  } else {
      cameraTexture.surfaceTextureListener = object :
            TextureView.SurfaceTextureListener {
          override
          fun onSurfaceTextureSizeChanged(
                surface: SurfaceTexture?,
                width: Int, height: Int) {
              configureTransform(width, height)
          }
          override
          fun onSurfaceTextureUpdated(
                surface: SurfaceTexture?) {
          }
          override
          fun onSurfaceTextureDestroyed(
                surface: SurfaceTexture?): Boolean {
              return true
          }
          override
          fun onSurfaceTextureAvailable(
                surface: SurfaceTexture?,
                 width: Int, height: Int) {
              camera.openCamera(width, height)
              configureTransform(width, height)
          }
     }
  }
}

private fun dataArrived(it: ByteArray) {
    Log.e(LOG_KEY, "Data arrived: " + it.size)
    // do more with the picture...
}

private fun configureTransform(
      viewWidth: Int, viewHeight: Int) {
    val matrix =
          previewDim.getTransformationMatrix(
          this, viewWidth, viewHeight)
    cameraTexture.setTransform(matrix)
}

onRequestPermissionsResult()回调用于在权限检查从相应的系统调用返回后开始预览。

override
fun onRequestPermissionsResult(requestCode: Int,
      permissions: Array<out String>,
      grantResults: IntArray) {
    super.onRequestPermissionsResult(requestCode,
          permissions, grantResults)
    when (requestCode) {
        PERM_REQUEST_CAMERA -> {
            if(grantResults[0] ==
                 PackageManager.PERMISSION_GRANTED) {
               start()
            }
        }
    }
  }
}

带有“拍照”按钮和自定义ciTextureView UI 元素的相应布局文件如下所示:

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

  <Button
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:text="Go"
      android:onClick="go"/>
  <de.pspaeth.camera2.AutoFitTextureView
      android:id="@+id/cameraTexture"
      android:layout_width="400dp"
      android:layout_height="200dp"
      android:layout_marginTop="8dp"
      />
</LinearLayout>

在这里,您必须使用自己的类的完全限定路径,而不是de.pspaeth.camera2.AutoFitTextureView

Camera类确保我们将重要的活动放入背景中,准备一个可以放置静态图像捕获数据的空间,并构建一个 camera session 对象。它还解决了几个规模问题。

/**
 * A camera with a preview sent to a TextureView
 */
class Camera(val activity: Activity,
      val previewDim:PreviewDimension,
      val textureView:TextureView) {
  companion object {
      val LOG_KEY = "camera"
      val STILL_IMAGE_FORMAT = ImageFormat.JPEG
      val STILL_IMAGE_MIN_WIDTH = 480
      val STILL_IMAGE_MIN_HEIGHT = 480
  }

  private val previewSizeListeners =
        mutableListOf<(Int,Int) -> Unit>()
  fun addPreviewSizeListener(
        l: (Int,Int) -> Unit ) {
      previewSizeListeners.add(l)
  }

  private val stillImageConsumers =
        mutableListOf<(ByteArray) -> Unit>()
  fun addStillImageConsumer(
        l: (ByteArray) -> Unit) {
      stillImageConsumers.add(l)
  }

/**

 * An additional thread and handler for running
 * tasks that shouldn't block the UI.
 */
private var mBackgroundThread: HandlerThread? = null
private var mBackgroundHandler: Handler? = null

private var cameraDevice: CameraDevice? = null
private val backfaceCamera =
      BackfaceCamera(activity)
      // Holds the backface camera's ID

/**
 * A [Semaphore] to prevent the app from exiting
 * before closing the camera.
 */
private val cameraOpenCloseLock = Semaphore(1)

private var imageReader:ImageReader? = null

private var paused = false

private var flashSupported = false

private var activeArraySize: Rect? = null

private var cameraSession:CameraSession? = null

private var stillImageBytes:ByteArray? = null

openCamera()方法检查权限,连接到摄像机数据输出,并启动与摄像机的连接。

fun openCamera(width: Int, height: Int) {
    startBackgroundThread()

    val permission1 =
          ContextCompat.checkSelfPermission(
          activity, Manifest.permission.CAMERA)
    if (permission1 !=
          PackageManager.PERMISSION_GRANTED) {
        Log.e(LOG_KEY,
              "Internal error: "+
              "Camera permission missing")
    }

    setUpCameraOutputs(width, height)
    val manager = activity.getSystemService(
          Context.CAMERA_SERVICE)
          as CameraManager
    try {
        if (!cameraOpenCloseLock.tryAcquire(
              2500, TimeUnit.MILLISECONDS)) {
            throw RuntimeException(
                  "Time out waiting.")
        }
        val mStateCallback = object :
              CameraDevice.StateCallback() {
            override
            fun onOpened(cameraDev: CameraDevice) {
                // This method is called when the
                // camera is opened.  We start camera
                // preview here.
                cameraOpenCloseLock.release()
                cameraDevice = cameraDev
                createCameraSession()
            }

            override
            fun onDisconnected(
                  cameraDev: CameraDevice) {
                cameraOpenCloseLock.release()
                cameraDevice?.close()
                cameraDevice = null
            }

            override
            fun onError(cameraDev: CameraDevice,
                  error: Int) {
                Log.e(LOG_KEY,
                      "Camera on error callback: "
                      + error);
                cameraOpenCloseLock.release()
                cameraDevice?.close()
                cameraDevice = null
            }
        }

        manager.openCamera(

              backfaceCamera.cameraId,
              mStateCallback,
              mBackgroundHandler)
    } catch (e: CameraAccessException) {
        Log.e(LOG_KEY,"Could not access camera", e)
    } catch (e: InterruptedException) {
        Log.e(LOG_KEY,
              "Interrupted while camera opening.", e)
    }
}

/**
 * Initiate a still image capture.
 */
fun takePicture() {
    cameraSession?.takePicture()
}

fun close() {
    stopBackgroundThread()
    cameraSession?.run {
        close()
    }
    imageReader?.run {
        surface.release()
        close()
        imageReader = null
    }
}

以下是处理后台线程和摄像机会话的几个私有方法:

//////////////////////////////////////////////////////
//////////////////////////////////////////////////////

/**
 * Starts a background thread and its [Handler].
 */
private fun startBackgroundThread() {
    mBackgroundThread =
          HandlerThread("CameraBackground")
    mBackgroundThread?.start()
    mBackgroundHandler = Handler(
          mBackgroundThread!!.getLooper())
}

/**
 * Stops the background thread and its [Handler].
 */
private fun stopBackgroundThread() {
    mBackgroundThread?.run {
        quitSafely()

        try {
            join()
            mBackgroundThread = null
            mBackgroundHandler = null
        } catch (e: InterruptedException) {
        }
    }
}

private fun createCameraSession() {
    cameraSession = CameraSession(mBackgroundHandler!!,
            cameraOpenCloseLock,
            backfaceCamera.characteristics,
            textureView,
            imageReader!!,
            cameraDevice!!,
            previewDim,
            activity.windowManager.defaultDisplay.
                  rotation,
            activeArraySize!!,
            1.0).apply {
        createCameraSession()
        addStillImageTakenConsumer {
            //Log.e(LOG_KEY, "!!! PICTURE TAKEN!!!")
            for (cons in stillImageConsumers) {
                mBackgroundHandler?.post(
                    Runnable {
                      stillImageBytes?.run{
                        cons(this)
                      }
                    })
            }
        }
    }
}

setUpCameraOutputs()方法执行连接到摄像机数据输出的艰苦工作。

/**
 * Sets up member variables related to camera:
 * activeArraySize, imageReader, previewDim,
 * flashSupported
 *
 * @param width    The width of available size for
 *       camera preview
 * @param height The height of available size for
 *       camera preview
 */
private fun setUpCameraOutputs(
      width: Int, height: Int) {
    activeArraySize = backfaceCamera.
          characteristics?.
          get(CameraCharacteristics.
          SENSOR_INFO_ACTIVE_ARRAY_SIZE)

    val map =
        backfaceCamera.characteristics!!.get(
            CameraCharacteristics.
            SCALER_STREAM_CONFIGURATION_MAP)

    val stillSize = calcStillImageSize(map)
    imageReader =
          ImageReader.newInstance(
                stillSize.width,
                stillSize.height,
          STILL_IMAGE_FORMAT, 3).apply {
        setOnImageAvailableListener(
            ImageReader.OnImageAvailableListener {
            reader ->
                if (paused)
                  return@OnImageAvailableListener
                val img = reader.acquireNextImage()
                val buffer = img.planes[0].buffer
                stillImageBytes =
                  ByteArray(buffer.remaining())
                buffer.get(stillImageBytes)
                img.close()
            }, mBackgroundHandler)
    }

    previewDim.calcPreviewDimension(width, height,
          activity, backfaceCamera)

    val texOutputSizes =
          map?.getOutputSizes(
          SurfaceTexture::class.java)
    val optimalSize =
          PreviewDimension.chooseOptimalSize(
            texOutputSizes,
            previewDim.rotatedPreviewWidth,
            previewDim.rotatedPreviewHeight,
            previewDim.maxPreviewWidth,
            previewDim.maxPreviewHeight,
            stillSize)
    previewDim.previewSize = optimalSize

    // We fit the aspect ratio of TextureView
    // to the size of preview we picked.
    val orientation =
          activity.resources.configuration.
          orientation
    if (orientation ==
          Configuration.ORIENTATION_LANDSCAPE) {
        previewSizeListeners.forEach{
              it(optimalSize.width,
                 optimalSize.height) }
    } else {
        previewSizeListeners.forEach{
              it(optimalSize.height,
                 optimalSize.width) }
    }

    // Check if the flash is supported.
    val available =
          backfaceCamera.characteristics?.
          get(CameraCharacteristics.
                FLASH_INFO_AVAILABLE)
    flashSupported = available ?: false
}

最后一个私有方法是计算静止图像的大小。一旦触发器被按下或触发器被模拟按下,这就起作用。

private fun calcStillImageSize(
      map: StreamConfigurationMap): Size {
    // For still image captures, we use the smallest
    // one at least some width x height
    val jpegSizes =
              map.getOutputSizes(ImageFormat.JPEG)
        var stillSize: Size? = null
        for (s in jpegSizes) {
           if (s.height >= STILL_IMAGE_MIN_HEIGHT
                 && s.width >= STILL_IMAGE_MIN_WIDTH) {
               if (stillSize == null) {
                   stillSize = s
               } else {
                   val f =
                         (s.width * s.height).toFloat()
                   val still =
                         (stillSize.width *
                          stillSize.height).toFloat()
                   if (f < still) {
                       stillSize = s
                   }
              }
          }
      }
      return stillSize ?: Size(100,100)
  }
}

我们需要的最后一个也可能是最复杂的类是CameraSession。它是一个状态机,处理各种相机状态,包括自动对焦和自动曝光,并提供两种数据消耗:预览纹理和捕获的静态图像存储。在解释这里使用的几个结构之前,我先给出清单:

/**
 * A camera session class.
 */
class CameraSession(val handler: Handler,
       val cameraOpenCloseLock:Semaphore,
       val cameraCharacteristics:CameraCharacteristics?,
       val textureView: TextureView,
       val imageReader: ImageReader,
       val cameraDevice: CameraDevice,
       val previewDim: PreviewDimension,
       val rotation:Int,
       val activeArraySize: Rect,
       val zoom: Double = 1.0) {
  companion object {
      val LOG_KEY = "Session"

    enum class State {
        STATE_PREVIEW,
            // Showing camera preview.
        STATE_WAITING_LOCK,
            // Waiting for the focus to be locked.
        STATE_WAITING_PRECAPTURE,
            // Waiting for the exposure to be
            // precapture state.
        STATE_WAITING_NON_PRECAPTURE,
            // Waiting for the exposure state to
            // be something other than precapture
        STATE_PICTURE_TAKEN
            // Picture was taken.
    }
}

var mState:State = State.STATE_PREVIEW

内部类MyCaptureCallback负责处理两种情况,预览和静态图像捕获。然而,对于预览版,状态转换仅限于onoff

inner class MyCaptureCallback :
      CameraCaptureSession.CaptureCallback() {
    private fun process(result: CaptureResult) {
      if(captSess == null)
          return
      when (mState) {
        State.STATE_PREVIEW -> {
            // We have nothing to do when the
            // camera preview is working normally.
        }
        State.STATE_WAITING_LOCK -> {
            val afState = result.get(
                  CaptureResult.CONTROL_AF_STATE)
            if (CaptureResult.
                  CONTROL_AF_STATE_FOCUSED_LOCKED
                  == afState
                 || CaptureResult.
                    CONTROL_AF_STATE_NOT_FOCUSED_LOCKED
                    == afState
                    || CaptureResult.
                     CONTROL_AF_STATE_PASSIVE_FOCUSED
                   == afState) {
                 if(cameraHasAutoExposure) {
                     mState =
                       State.STATE_WAITING_PRECAPTURE
                     runPrecaptureSequence()
                 } else {
                     mState =
                       State.STATE_PICTURE_TAKEN
                     captureStillPicture()
                 }
             }
         }
         State.STATE_WAITING_PRECAPTURE -> {
             val aeState = result.get(
                   CaptureResult.CONTROL_AE_STATE)
             if (aeState == null ||
                   aeState == CaptureResult.
                     CONTROL_AE_STATE_PRECAPTURE
                   ||
                   aeState == CaptureRequest.
                     CONTROL_AE_STATE_FLASH_REQUIRED) {
                 mState =
                     State.STATE_WAITING_NON_PRECAPTURE
              }
          }
          State.STATE_WAITING_NON_PRECAPTURE -> {
              val aeState = result.get(
                    CaptureResult.CONTROL_AE_STATE)
              if (aeState == null ||
                    aeState != CaptureResult.
                        CONTROL_AE_STATE_PRECAPTURE) {
                  mState = State.STATE_PICTURE_TAKEN
                  captureStillPicture()
              }
          }
          else -> {}
      }
  }

  override
  fun onCaptureProgressed(
        session: CameraCaptureSession,
        request: CaptureRequest,
        partialResult: CaptureResult) {
      //...
  }

  override
  fun onCaptureCompleted(
        session: CameraCaptureSession,
        request: CaptureRequest,
        result: TotalCaptureResult) {
      process(result)
  }
}

var captSess: CameraCaptureSession? = null
var cameraHasAutoFocus = false
var cameraHasAutoExposure = false
val captureCallback = MyCaptureCallback()

private val stillImageTakenConsumers =
      mutableListOf<() -> Unit>()
fun addStillImageTakenConsumer(l: () -> Unit) {
    stillImageTakenConsumers.add(l)
}

自动对焦动作仅限于支持它的相机设备。这是在createCameraSession()开始时检查的。同样,自动曝光动作仅限于适当的设备。

/**
 * Creates a new [CameraCaptureSession] for camera
 * preview and taking pictures.
 */
fun createCameraSession() {
    //Log.e(LOG_KEY,"Starting preview session")

    cameraHasAutoFocus = cameraCharacteristics?.
          get(CameraCharacteristics.
          CONTROL_AF_AVAILABLE_MODES)?.let {
        it.any{ it ==
              CameraMetadata.CONTROL_AF_MODE_AUTO }
    } ?: false

    cameraHasAutoExposure = cameraCharacteristics?.

        get(CameraCharacteristics.
        CONTROL_AE_AVAILABLE_MODES)?.let {
      it.any{ it == CameraMetadata.
              CONTROL_AE_MODE_ON ||
          it == CameraMetadata.
              CONTROL_AE_MODE_ON_ALWAYS_FLASH ||
          it == CameraMetadata.
              CONTROL_AE_MODE_ON_AUTO_FLASH ||
          it == CameraMetadata.
              CONTROL_AE_MODE_ON_AUTO_FLASH_REDEYE }
    } ?: false

    try {
        val texture = textureView.getSurfaceTexture()
        // We configure the size of default buffer
        // to be the size of camera preview we want.
        texture.setDefaultBufferSize(
              previewDim.previewSize!!.width,
              previewDim!!.previewSize!!.height)
        // This is the output Surface we need to start
        // preview.
        val previewSurface = Surface(texture)
        val takePictureSurface = imageReader.surface

有两个相机输出消费者:用于预览的纹理和用于静态图像捕获的图像读取器。两者都是构造函数参数,都用于创建会话对象;参见cameraDevice.createCaptureSession(...)

            // Here, we create a CameraCaptureSession for
            // both camera preview and taking a picture
            cameraDevice.
            createCaptureSession(Arrays.asList(
                  previewSurface, takePictureSurface),
                object : CameraCaptureSession.
                         StateCallback() {
                    override
                    fun onConfigured(cameraCaptureSession:
                          CameraCaptureSession) {
                        // When the session is ready, we
                        // start displaying the preview.
                        captSess = cameraCaptureSession
                        try {

                        val captReq =
                          buildPreviewCaptureRequest()
                        captSess?.
                          setRepeatingRequest(captReq,
                                captureCallback,
                                handler)
                    } catch (e: Exception) {
                        Log.e(LOG_KEY,
                        "Cannot access camera "+
                        "in onConfigured()", e)
                    }
                }
                override fun onConfigureFailed(
                        cameraCaptureSession:
                        CameraCaptureSession) {
                    Log.e(LOG_KEY,
                        "Camera Configuration Failed")
                }
                override fun onActive(
                      sess: CameraCaptureSession) {
                }
                override fun onCaptureQueueEmpty(
                      sess: CameraCaptureSession) {
                }
                override fun onClosed(
                      sess: CameraCaptureSession) {
                }
                override fun onReady(
                      sess: CameraCaptureSession) {
                }
                override fun onSurfacePrepared(
                      sess: CameraCaptureSession, surface: Surface) {
                }
            }, handler
        )
    } catch (e: Exception) {
        Log.e(LOG_KEY, "Camera access failed", e)
    }
}

/**
 * Initiate a still image capture.
 */
fun takePicture() {
    lockFocusOrTakePicture()
}

fun close() {
    try {
        cameraOpenCloseLock.acquire()
        captSess?.run {
            stopRepeating()
            abortCaptures()
            close()
            captSess = null
        }
        cameraDevice.run {
            close()
        }
    } catch (e: InterruptedException) {
        Log.e(LOG_KEY,
              "Interrupted while trying to lock " +
              "camera closing.", e)
    } catch (e: CameraAccessException) {
        Log.e(LOG_KEY, "Camera access exception " +
              "while closing.", e)
    } finally {
        cameraOpenCloseLock.release()
    }
}

以下是私有方法。各种各样的build*CaptureRequest()方法展示了如何准备一个请求,然后发送到摄像机硬件。

//////////////////////////////////////////////////////
//////////////////////////////////////////////////////

private fun buildPreviewCaptureRequest():
      CaptureRequest {
    val texture = textureView.getSurfaceTexture()
    val surface = Surface(texture)

    // We set up a CaptureRequest.Builder with the
    // preview output Surface.

    val reqBuilder = cameraDevice.
          createCaptureRequest(
          CameraDevice.TEMPLATE_PREVIEW)
    reqBuilder.addTarget(surface)

    // Zooming
    val cropRect = calcCropRect()
    reqBuilder.set(
          CaptureRequest.SCALER_CROP_REGION,
          cropRect)

    // Flash off
    reqBuilder.set(CaptureRequest.FLASH_MODE,
          CameraMetadata.FLASH_MODE_OFF)

    // Continuous autofocus
    reqBuilder.set(CaptureRequest.CONTROL_AF_MODE,
          CaptureRequest.
          CONTROL_AF_MODE_CONTINUOUS_PICTURE)
    return reqBuilder.build()
}

private fun buildTakePictureCaptureRequest() :
      CaptureRequest {
    // This is the CaptureRequest.Builder that we use
    // to take a picture.
    val captureBuilder =
          cameraDevice.createCaptureRequest(
          CameraDevice.TEMPLATE_STILL_CAPTURE)
    captureBuilder.addTarget(imageReader.getSurface())

    // Autofocus mode
    captureBuilder.set(CaptureRequest.CONTROL_AF_MODE,
            CaptureRequest.
            CONTROL_AF_MODE_CONTINUOUS_PICTURE)

    // Flash auto
    captureBuilder.set(CaptureRequest.CONTROL_AE_MODE,
            CaptureRequest.
            CONTROL_AE_MODE_ON_AUTO_FLASH)
    // captureBuilder.set(CaptureRequest.FLASH_MODE,
    // CameraMetadata.FLASH_MODE_OFF)

        // Zoom
        val cropRect = calcCropRect()
        captureBuilder.set(CaptureRequest.
              SCALER_CROP_REGION, cropRect)

        // Orientation
        captureBuilder.set(CaptureRequest.
              JPEG_ORIENTATION,
              previewDim.getOrientation(rotation))
        return captureBuilder.build()
    }

    private fun buildPreCaptureRequest() :
          CaptureRequest {
        val surface = imageReader.surface
        val reqBuilder =
              cameraDevice.createCaptureRequest(
              CameraDevice.TEMPLATE_STILL_CAPTURE)
        reqBuilder.addTarget(surface)
        reqBuilder.set(CaptureRequest.
              CONTROL_AE_PRECAPTURE_TRIGGER,
              CaptureRequest. CONTROL_AE_PRECAPTURE_TRIGGER_START)
        return reqBuilder.build()
    }

    private fun buildLockFocusRequest() :
          CaptureRequest {
        val surface = imageReader.surface
        val reqBuilder =
              cameraDevice.createCaptureRequest(
              CameraDevice.TEMPLATE_STILL_CAPTURE)
        reqBuilder.addTarget(surface)
        reqBuilder.set(CaptureRequest.
              CONTROL_AF_TRIGGER,
              CameraMetadata.CONTROL_AF_TRIGGER_START)
        return reqBuilder.build()
    }

    private fun buildCancelTriggerRequest() :
          CaptureRequest {
        val texture = textureView.getSurfaceTexture()
        val surface = Surface(texture)

        val reqBuilder =
              cameraDevice.createCaptureRequest(
              CameraDevice.TEMPLATE_PREVIEW)
        reqBuilder.addTarget(surface)
        reqBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER,
              CameraMetadata.CONTROL_AF_TRIGGER_CANCEL)
        return reqBuilder.build()
    }

捕捉静态图片由方法captureStillPicture()处理。注意,像许多其他与相机相关的功能一样,适当的任务被发送到后台,回调处理后台处理结果。

private fun captureStillPicture() {
    val captureRequest =
          buildTakePictureCaptureRequest()
    if (captSess != null) {
      try {
        val captureCallback = object :
              CameraCaptureSession.CaptureCallback() {
            override fun onCaptureCompleted(
                  session: CameraCaptureSession,
                  request: CaptureRequest,
                  result: TotalCaptureResult) {
                //Util.showToast(activity,
                //"Acquired still image")
                stillImageTakenConsumers.forEach {
                      it() }
                unlockFocusAndBackToPreview()
            }
        }
        captSess?.run {
            stopRepeating()
            capture(captureRequest,
                  captureCallback, null)
        }
      } catch (e: Exception) {
          Log.e(LOG_KEY,
                "Cannot capture picture", e)
      }
    }
}

private fun lockFocusOrTakePicture() {
    if(cameraHasAutoFocus) {
        captSess?.run {
          try {
            val captureRequest =
                  buildLockFocusRequest()
            mState = State.STATE_WAITING_LOCK
                capture(captureRequest,
                        captureCallback,
                        handler)
          } catch (e: Exception) {
              Log.e(LOG_KEY,
                    "Cannot lock focus", e)
          }
        }
    } else {
        if(cameraHasAutoExposure) {
            mState = State.STATE_WAITING_PRECAPTURE
            runPrecaptureSequence()
        } else {
            mState = State.STATE_PICTURE_TAKEN
            captureStillPicture()
        }
    }
}

/**
 * Unlock the focus. This method should be called when
 * still image capture sequence is finished.
 */
private fun unlockFocusAndBackToPreview() {
    captSess?.run {
        try {
            mState = State.STATE_PREVIEW
            val cancelAfTriggerRequest =
                  buildCancelTriggerRequest()
            val previewRequest =
                  buildPreviewCaptureRequest()
            capture(cancelAfTriggerRequest,
                    captureCallback,
                    handler)
            setRepeatingRequest(previewRequest,
                    captureCallback,
                    handler)
        } catch (e: Exception) {
            Log.e(LOG_KEY,
            "Cannot go back to preview mode", e)
        }
    }
}

运行捕捉静止图像的预捕捉序列由方法runPrecaptureSequence()执行。当我们在captureCallback中从方法lockFocusThenTakePicture()得到响应时,应该调用这个方法。

  /**
   * Run the precapture sequence for capturing a still
   * image.
   */
  private fun runPrecaptureSequence() {
      try {
        captSess?.run {
           val captureRequest = buildPreCaptureRequest()
           mState = State.STATE_WAITING_PRECAPTURE
           capture(captureRequest, captureCallback,
                   handler)
         }
       } catch (e: Exception) {
           Log.e(LOG_KEY, "Cannot access camera", e)
       }
  }

  private fun calcCropRect(): Rect {
      with(activeArraySize) {
          val cropW = width() / zoom
          val cropH = height() / zoom
          val top = centerY() - (cropH / 2f).toInt()
          val left = centerX() - (cropW / 2f).toInt()
          val right = centerX() + (cropW / 2f).toInt()
          val bottom = centerY() + (cropH / 2f).toInt()
          return Rect(left, top, right, bottom)
      }
    }
}

这里有一些关于CameraSession类的注释:

  • 模拟器不具备自动对焦功能。守则会照顾到这一点。

  • 术语预捕捉只是自动曝光的另一个名称。

  • 使用闪光灯是这门课的一个todo。要启用 flash,请查看代码中提到 flash 的地方。

  • 借助于从CameraSession开始的监听器链,静止图像捕获数据最终到达MainActivitydataArrived(...)方法。在那里,您可以开始编写进一步的处理算法,如保存、发送、转换、读取等。

安卓和 NFC

如果 Android 设备有 NFC 适配器,NFC 适配器允许与其他支持 NFC 的设备或 NFC 标签进行短程无线通信。我们在第十二章谈到了 NFC。

安卓和蓝牙

大多数(如果不是全部的话)现代安卓设备都内置了蓝牙。通过蓝牙,他们可以与其他蓝牙设备进行无线通信。详情请参见第十二章。

安卓传感器

Android 设备向应用提供关于其环境的各种信息,如下所示:

  • 由罗盘或陀螺仪确定的方向

  • 由加速力引起的运动

  • 重力

  • 气温、气压、湿度

  • 照明

  • 接近度,例如找出到用户耳朵的距离

传感器检测不到设备的确切地理空间位置。关于使用 GPS 检测位置坐标,请参见第八章。

检索传感器功能

从 Android 4.0 (API level 14)开始,Android 设备应该提供各种android定义的所有传感器类型。hardwareSensor.TYPE_*常数。要查看所有传感器的列表,包括关于它们的各种信息,请使用以下代码片段:

val sensorManager = getSystemService(
      Context.SENSOR_SERVICE) as SensorManager
val deviceSensors =
      sensorManager.getSensorList(Sensor.TYPE_ALL)
deviceSensors.forEach { sensor ->
  Log.e("LOG", "+++" + sensor.toString())
}

要获取某个传感器,请使用以下命令:

val magneticFieldSensor = sensorManager.getDefaultSensor(
      Sensor.TYPE_MAGNETIC_FIELD)

一旦你有了一个Sensor对象,你就可以获得关于它的各种信息。详见android.hardware.Sensor的 API 文档。要找出传感器值,请参见下一节。

监听传感器事件

Android 支持以下两种传感器事件监听器:

  • 传感器精度的变化

  • 传感器值的变化

要注册一个监听器,获取传感器管理器和传感器,如前一节所述,然后在活动中使用如下内容:

val sensorManager = getSystemService(
      Context.SENSOR_SERVICE) as SensorManager
val magneticFieldSensor = sensorManager.getDefaultSensor(
      Sensor.TYPE_MAGNETIC_FIELD)
sensorManager.registerListener(this,
      magneticFieldSensor,
      SensorManager.SENSOR_DELAY_NORMAL)

对于时间分辨率,您也可以使用其他延迟规格之一:SensorManager.SENSOR_DELAY_*

然后,活动必须覆盖android.hardware.SensorEventListener并实现它。

class MainActivity : AppCompatActivity(),
      SensorEventListener {
  private lateinit var sensorManager:SensorManager
  private lateinit var magneticFieldSensor:Sensor

  override fun onCreate(savedInstanceState: Bundle?) {
      ...
      sensorManager =
            getSystemService(Context.SENSOR_SERVICE)
            as SensorManager
      magneticFieldSensor =
            sensorManager.getDefaultSensor(
            Sensor.TYPE_MAGNETIC_FIELD)
  }

  override
  fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
      // Do something here if sensor accuracy changes.
  }

  override
  fun onSensorChanged(event: SensorEvent) {
      Log.e("LOG", Arrays.toString(event.values))
      // Do something with this sensor value.
  }

  override
  fun onResume() {
      super.onResume()
      sensorManager.registerListener(this,
            magneticFieldSensor,
      SensorManager.SENSOR_DELAY_NORMAL)
  }

  override
  fun onPause() {
      super.onPause()
      sensorManager.unregisterListener(this)
  }
}

如此例所示,当不再需要传感器事件侦听器时,将其注销是很重要的,因为传感器可能会耗尽电池电量。

注意

顾名思义,onSensorChanged事件可能会被触发,即使传感器值并没有真正发生变化。

你从onSensorChanged()内的SensorEvent.values获得的所有可能的传感器值都列在表 13-3 中。

表 13-3

传感器事件值

|

类型

|

价值观念

| | --- | --- | | TYPE_ACCELEROMETER | 矢量 3:沿 x-y-z 轴的加速度,单位为 / 。包括重力。 | | TYPE_AMBIENT_TEMPERATURE | 标量:环境空气温度,单位为摄氏度。 | | TYPE_GRAVITY | 矢量 3:沿 x-y-z 轴的重力在 m / s 2 方向。 | | TYPE_GYROSCOPE | 向量 3:绕 x-y-z 轴的旋转速率,单位为拉德 / 。 | | TYPE_LIGHT | 标量:照度,单位为 lx 。 | | TYPE_LINEAR_ACCELERATION | 矢量 3:沿 x-y-z 轴的加速度,单位为 / 。没有重力。 | | TYPE_MAGNETIC_FIELD | Vector3:地磁场的强度,单位为 μT 。 | | TYPE_ORIENTATION | 向量 3:方位角,俯仰角,滚动角度。 | | TYPE_PRESSURE | 标量:以 hP a 为单位的环境空气压力。 | | TYPE_PROXIMITY | 标量:距离物体的距离,单位为厘米。 | | TYPE_RELATIVE_HUMIDITY | 标量:以%表示的环境相对湿度。 | | TYPE_ROTATION_VECTOR | 向量 4:四元数形式的旋转向量。 | | TYPE_SIGNIFICAT_MOTION | 每次检测到重大运动时,都会触发该事件。要参加此次活动,您必须通过SensorManager.requestTriggerSensor(...)注册。 | | TYPE_STEP_COUNTER | 标量:自重新启动和传感器激活后的累计步数。 | | TYPE_STEP_DETECTOR | 每次检测到一个步骤时都会触发该事件。 | | TYPE_TEMPERATURE | 已弃用。标量:设备的温度,单位为摄氏度。 |

一些传感器具有未校准版本,这意味着它们显示变化更准确,但与固定点相关的精度较低:

  • TYPE_ACCELEROMETER_UNCALIBRATED

  • TYPE_GYROSCOPE_UNCALIBRATED

  • TYPE_MAGNETIC_FIELD_UNCALIBRATED.

除了TYPE_ROTATION_VECTOR传感器,您还可以使用以下传感器之一:

  • TYPE_GAME_ROTATION_VECTOR

  • TYPE_GEOMAGNETIC_ROTATION_VECTOR

第一种不使用陀螺仪,对于探测变化更精确,但是对于找出北在哪里不是很精确。第二种使用磁场而不是陀螺仪;它不太准确,但也需要较少的电池电量。

与电话互动

Android 允许多种方式来与呼入或呼出电话以及拨号过程进行交互。以下是您的应用可能为电话实现的最突出的用例:

  • 监控电话的状态变化,如通知来电和去电

  • 启动拨号过程以开始拨出电话

  • 为管理呼叫提供自己的用户界面

您可以在包android.telecomandroid.telephony及其子包中找到电话相关的类和接口。

监控电话状态变化

要监控电话状态变化,请将以下权限添加到AndroidManifest.xml:

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

READ_PHONE_STATE权限允许您检测正在进行的通话的状态。PROCESS_OUTGOING_CALLS权限允许你的应用查看呼出电话的数量,甚至使用不同的号码或取消通话。

要了解如何从您的应用中获取权限,请参见第七章。

为了收听与电话相关的事件,您可以在AndroidManifest.xml中添加一个广播接收器。

<application>
  ...
  <receiver android:name=".CallMonitor">
    <intent-filter>
      <action android:name=
            "android.intent.action.PHONE_STATE" />
      </intent-filter>
      <intent-filter>
      <action android:name=
            "android.intent.action.NEW_OUTGOING_CALL" />
      </intent-filter>
  </receiver>
</application>

例如,您可以按如下方式实现它:

package ...

import android.telephony.TelephonyManager as TM
import ...

class CallMonitor : BroadcastReceiver() {
    companion object {
        private var lastState = TM.CALL_STATE_IDLE
        private var callStartTime: Date? = null
        private var isIncoming: Boolean = false
        private var savedNumber: String? = null
    }

onReceive()回调处理传入的广播,这次是传入或传出的呼叫。

override
fun onReceive(context: Context, intent: Intent) {
    if (intent.action ==
          Intent.ACTION_NEW_OUTGOING_CALL) {
        savedNumber = intent.extras!!.
              getString(Intent.EXTRA_PHONE_NUMBER)
    } else {
        val stateStr = intent.extras!!.
              getString(TM.EXTRA_STATE)
        val number = intent.extras!!.
              getString(TM.EXTRA_INCOMING_NUMBER)
        val state = when(stateStr) {
            TM.EXTRA_STATE_IDLE ->
                TM.CALL_STATE_IDLE
            TM.EXTRA_STATE_OFFHOOK ->
                TM.CALL_STATE_OFFHOOK
            TM.EXTRA_STATE_RINGING ->
                TM.CALL_STATE_RINGING
            else -> 0
        }
        callStateChanged(context, state, number)
    }
}

protected fun onIncomingCallReceived(
      ctx: Context, number: String?, start: Date){
    Log.e("LOG",
          "IncomingCallReceived ${number} ${start}")
}

protected fun onIncomingCallAnswered(
      ctx: Context, number: String?, start: Date) {
    Log.e("LOG",
          "IncomingCallAnswered ${number} ${start}")
}

protected fun onIncomingCallEnded(
      ctx: Context, number: String?,
      start: Date?, end: Date) {
    Log.e("LOG",
          "IncomingCallEnded ${number} ${start}")
}

protected fun onOutgoingCallStarted(
      ctx: Context, number: String?, start: Date) {
    Log.e("LOG",
          "OutgoingCallStarted ${number} ${start}")
}

protected fun onOutgoingCallEnded(
      ctx: Context, number: String?,
      start: Date?, end: Date) {
    Log.e("LOG",
          "OutgoingCallEnded ${number} ${start}")
}

protected fun onMissedCall(
      ctx: Context, number: String?, start: Date?) {
    Log.e("LOG",
          "MissedCall ${number} ${start}")
}

私有方法callStateChanged()对与电话呼叫相对应的各种状态变化做出反应。

/**
 * Incoming call:
 *     IDLE -> RINGING when it rings,
 *     -> OFFHOOK when it's answered,
 *     -> IDLE when its hung up
 * Outgoing call:
 *     IDLE -> OFFHOOK when it dials out,
 *     -> IDLE when hung up
 * */
private fun callStateChanged(
      context: Context, state: Int, number: String?) {
    if (lastState == state) {
        return // no change in state
    }
    when (state) {
        TM.CALL_STATE_RINGING -> {
            isIncoming = true
            callStartTime = Date()
            savedNumber = number
            onIncomingCallReceived(
                  context, number, callStartTime!!)
        }
        TM.CALL_STATE_OFFHOOK ->
            if (lastState != TM.CALL_STATE_RINGING) {
                isIncoming = false
                callStartTime = Date()
                onOutgoingCallStarted(context,
                      savedNumber, callStartTime!!)
            } else {
                isIncoming = true
                callStartTime = Date()
                onIncomingCallAnswered(context,
                      savedNumber, callStartTime!!)
            }
        TM.CALL_STATE_IDLE ->
            if (lastState == TM.CALL_STATE_RINGING) {
                //Ring but no pickup- a miss
                onMissedCall(context,
                      savedNumber, callStartTime)
                } else if (isIncoming) {
                    onIncomingCallEnded(context,
                    savedNumber, callStartTime,
                    Date())
            } else {
                onOutgoingCallEnded(context,
                      savedNumber, callStartTime,
                      Date())
            }
        }
       lastState = state
    }
}

使用这样的监听器,您可以收集有关电话使用的统计信息,创建优先电话号码列表,或者做其他有趣的事情。要连接带有联系信息的电话,请参见第八章。

Initiate a Dialing Process

要从您的应用中启动拨号过程,您基本上有两种选择。

  • 开始拨号过程;用户可以看到并更改被叫号码。

  • 开始拨号过程;用户不能改变被叫号码。

对于第一种情况,向用户显示号码并让他们更改,您不需要任何特殊权限。只需写下以下内容:

val num = "+34111222333"
val intent = Intent(Intent.ACTION_DIAL,
      Uri.fromParts("tel", num, null))
startActivity(intent)

要用指定的号码开始拨号,您需要以下权限作为附加权限:

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

要了解如何获得它,请参见第七章。然后,可以通过以下方式启动呼叫过程:

val num = "+34111222333"
val intent = Intent(Intent.ACTION_CALL,
      Uri.fromParts("tel", num, null))
startActivity(intent)

创建电话呼叫自定义用户界面

创建你自己的电话呼叫活动,包括它自己的 UI,在 Android 文档的在线页面“构建一个呼叫应用”中有描述。

指纹认证

指纹认证随着 Android 6.0 版(API 级)进入 Android 框架。在此之前,您必须使用特定于供应商的 API。以下假设你针对的是 Android 6.0 或更新版本。

只有当用户的设备有指纹扫描仪时,使用指纹扫描仪才有意义。要检查是否是这种情况,请使用以下代码片段:

val useFingerprint =
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
    (getSystemService(Context.FINGERPRINT_SERVICE)
     as FingerprintManager).let {
        it.isHardwareDetected &&
        it.hasEnrolledFingerprints()
    }
  } else false

这在 Android P 中已被否决。从 Android P 开始的替代方法是尝试执行一个认证并捕捉适当的错误消息。

现在要开始一个指纹认证过程,你首先必须决定是使用现在已经废弃的FingerPrintManager类还是从 Android P 开始的新的FingerprintDialog

要使用不赞成使用的FingerPrintManager类进行身份验证,您可以提供一个回调,然后在其上调用authenticate(...)方法。

val mngr = getSystemService(Context.FINGERPRINT_SERVICE)
      as FingerprintManager
val cb = object :
      FingerprintManager.AuthenticationCallback() {
  override
  fun onAuthenticationSucceeded(
      result: FingerprintManager.AuthenticationResult) {
      ...
  }
  override
  fun onAuthenticationFailed() {
      ...
  }
}
val cs = CancellationSignal()
mngr.authenticate(null, cs, 0, cb, null)

要使用FingerprintDialog,您可以类似地启动一个认证过程,调用authenticate()并对认证结果做出适当的反应。

注意

截至 2018 年 4 月,只存在一个FingerprintDialog的开发者预览版。