老司机带你吃牛轧糖 -- 适配 Android 7.1 Nougat 新特性

2,260 阅读15分钟
原文链接: www.jianshu.com

What's new in Android 7.1 Nougat?

Android 7.1 Nougat 已经推出有一段时间,相信大多数人和我一样,并没有用上最新的系统,但是,总有一群走在时代的前列线上的Geek们,勇于尝鲜,艰苦奋斗,为刷新版本号贡献自己的力量。好吧,实际上就是我还没有用上7.1,有些眼馋了。那么,和开发者息息相关的有哪些新特性呢?


Android 7.1 Nougat

本次主要介绍3个新特性:App Shortcuts, Round Icon ResourceImage Keyboard Support。所有的新特性可以访问谷歌开发者中文博客的文章欢迎使用Android 7.1.1 Nougat

App Shortcuts

作为一个密切关注Android发展的伪Geek,在7.1正式版未发布之前,通过网上的一些爆料文章,我就了解到了这一新功能。实际上,这个功能刚开始出现时,我还以为Google Pixel要上压感屏了呢,事实证明,的确是我想多了。

App Shortcuts允许用户直接在启动器中显示一些操作,让用户立即执行应用的深层次的功能。触发这一功能的操作就是「长按」。这一功能类似于iOS中的「3D Touch」。

下面通过一张GIF,直观的感受一下App Shortcuts是怎样的。(由于我的一加3并没有升级到最新的7.1,还只是7.0,所以我安装了Nova Launcher来体验。)


App Shortcuts

长按图标,收到震动后松手,如果能够看到图标上弹出了支持的跳转操作,说明成功的呼出了Shortcuts功能,如果不支持这一功能,在Nova Launcher上弹出的就是卸载或者移除操作,在Pixel Launcher上不会出现弹出菜单,显示的是常见的长按操作。长按弹出的操作,可以将这个操作已快捷方式图标的形式直接放置在主屏上。如果长按主图标不松手,就可以调整位置了。

目前,一个应用最多可以支持 5 个Shortcut,可以通过getMaxShortcutCountPerActivity查看Launcher最多支持Shortcut的数量。每一个Shortcut都对应着一个或者多个intent,当用户选择某一个Shortcut时,应该做出特定的动作。下面是一些将一些特定的动作作为Shortcuts的例子:

  • 在地图APP中,指引用户至最常用的位置

  • 在聊天APP中,发送信息至某个好友

  • 在多媒体APP中,播放下一个电视节目

  • 在游戏APP中,加载至上次保存的地方

App Shortcut可以分为两种不同的类型: Static Shortcuts(静态快捷方式) 和 Dynamic Shortcuts(动态快捷方式)。

  • Static Shortcuts:在打包到apk的资源文件中定义,所以,直到下一次更新版本时才能改变静态快捷方式的详细说明。
  • Dynamic Shortcuts:通过ShortcutManager API在运行时发布,在运行时,应用可以发布,升级和移除快捷方式。

Using Static Shortcuts

创建Static Shortcuts分为以下几步:

1.在工程的manifest文件 (AndroidManifest.xml)下,找到 intent filter设置为 android.intent.action.MAINandroid.intent.category.LAUNCHER 的Activity。

2.在次Activity下添加<meta-data>标签,引用定义shortcuts的资源文件。

<activity
        android:name=".homepage.MainActivity"
        android:configChanges="orientation|keyboardHidden|screenSize|screenLayout"
        android:label="@string/app_name"
        android:theme="@style/AppTheme.NoActionBar">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />

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

        <meta-data
            android:name="android.app.shortcuts"
            android:resource="@xml/shortcuts" />
    </activity>

3.创建新的资源文件res/xml/shortcuts.xml

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

    <shortcut
        android:enabled="true"
        android:icon="@drawable/ic_search_circle"
        android:shortcutId="search_bookmarks"
        android:shortcutShortLabel="@string/search_bookmarks"
        android:shortcutLongLabel="@string/search_bookmarks">

        <intent
            android:action="android.intent.action.VIEW"
            android:targetPackage="com.marktony.zhihudaily"
            android:targetClass="com.marktony.zhihudaily.search.SearchActivity" />

        <!--如果你的一个shortcut关联着多个intent,你可以在这里继续添
            加。最后一个intent决定着用户在加载这个shortcut时会看到什么-->

        <categories android:name="android.shortcut.conversation" />

    </shortcut>

    <!--在这里添加更多的shortcut-->

</shortcuts>

shortcut下标签的含义:

  • enabled:见名知意,shortcut是否可用。如果你决定让这个static shortcut不在可用的话,可直接将其设置为 false ,或者直接从 shortcuts 标签中移除。

  • icon:显示在左边的图标,可用使用Vector drawable

  • shortcutDisabledMessage:当禁用此shortcut后,它仍然会出现在用户长按应用图标后的快捷方式列表里,也可以被拖动并固定到桌面上,但是它会呈现灰色并且用户点击时会弹出Toast这个标签所定义的内容。

  • shortcutLongLabel:当启动器有足够多的空间时,会显示这个标签所定义的内容。

  • shortcutShortLabel:shortcut的简要说明,是必需字段。当shortcut被添加到桌面上时,显示的也是这个字段。

  • intent:shortcut关联的一个或者多个intent,当用户点击shortcut时被打开。

  • shortcutId:shortcut的唯一标示id,若存在具有相同shortcutId的shortcut,则只显示一个。

到这里,最简单的shortcut就添加成功了。运行包含上面的文件的项目,点击shortcut就可以直接进入 SearchActivity,当按下back键时,直接就退出了应用。如果希望不退出应用,而是进入 MainActivity 时,应该怎么办呢?不用着急,在shortcut继续添加intent就可以了。

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

    <shortcut
        android:enabled="true"
        android:icon="@drawable/ic_search_circle"
        android:shortcutId="search_bookmarks"
        android:shortcutShortLabel="@string/search_bookmarks"
        android:shortcutLongLabel="@string/search_bookmarks">

        <intent
                android:action="android.intent.action.MAIN"
                android:targetClass="com.marktony.zhihudaily.homepage.MainActivity"
            android:targetPackage="com.marktony.zhihudaily" />

        <intent
            android:action="android.intent.action.VIEW"
            android:targetPackage="com.marktony.zhihudaily"
            android:targetClass="com.marktony.zhihudaily.search.SearchActivity" />

        <categories android:name="android.shortcut.conversation" />

    </shortcut>

      <!--在这里添加更多的shortcut-->

 </shortcuts>

Using Dynamic Shortcuts

动态快捷方式应该和应用内的特定的、上下文敏感的action链接。这些action应该可以在用户的几次使用之间、甚至是在应用运行过程中被改变。好的候选action包括打电话给特定的人、导航至特定的地方、或者展示当前游戏的分数。

ShortcutManager API允许我们在动态快捷方式上完成下面的操作:

  • 发布:使用setDynamicShortcuts()重新定义整个动态快捷方式列表,或者是使用addDynamicShortcuts()向已存在的动态快捷方式列表中添加快捷方式。

  • 更新:使用updateShortcuts()方法。

  • 移除:使用removeDynamicShortcuts()方法移除特定动态快捷方式或者使用removeAllDynamicShortcuts()移除所有动态快捷方式。

下面是在MainActivity的onCreate()中创建动态快捷方式的例子:

@Override
protected void onCreate(Bundle savedInstanceState) {

    ...

    ShortcutManager shortcutManager = getSystemService(ShortcutManager.class);

    ShortcutInfo webShortcut = new ShortcutInfo.Builder(this, "shortcut_web")
            .setShortLabel("github")
            .setLongLabel("Open Tonny's github web site")
            .setIcon(Icon.createWithResource(this, R.drawable.ic_dynamic_shortcut))
            .setIntent(new Intent(Intent.ACTION_VIEW, Uri.parse("https://marktony.github.io")))
            .build();

    shortcutManager.setDynamicShortcuts(Collections.singletonList(webShortcut));
}

也可以为动态快捷方式创建返回栈。

@Override
protected void onCreate(Bundle savedInstanceState) {

    ...

    ShortcutInfo dynamicShortcut = new ShortcutInfo.Builder(this, "shortcut_dynamic")
            .setShortLabel("Dynamic")
            .setLongLabel("Open dynamic shortcut")
            .setIcon(Icon.createWithResource(this, R.drawable.ic_dynamic_shortcut_2))
            .setIntents(
                    new Intent[]{
                            new Intent(Intent.ACTION_MAIN, Uri.EMPTY, this, MainActivity.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK),
                            new Intent(DynamicShortcutActivity.ACTION)
                    })
            .build();

    shortcutManager.setDynamicShortcuts(Arrays.asList(webShortcut, dynamicShortcut));
}

创建一个新的空的Activity,名字叫做DynamicShortcutActivity,在manifest文件中注册。

<activity  
      android:name=".DynamicShortcutActivity"
      android:label="Dynamic shortcut activity">
      <intent-filter>
        <action android:name="com.marktony.zhihudaily.OPEN_DYNAMIC_SHORTCUT" />
        <category android:name="android.intent.category.DEFAULT" />
      </intent-filter>
</activity>

通过清除array中的排序过的intents,当我们通过创建好的shortcut进入DynamicShortcutActivity之后,按下back键,MainActivity就会被加载。

需要注意的是,在动态创建快捷方式之前,最好是检查一下是否超过了所允许的最大值。否则会抛出相应的异常。

Extra Bits

  • 当static shortcut 和 dynamic shortcut一起展示时,其出现的顺序是怎样定制呢?

    ShortcutInfo.Builder 中有一个专门的方法 setRank(int) ,通过设置不同的等级,我们就可以控制动态快捷方式的出现顺序,等级越高,出现在快捷方式列表中的位置就越高。

  • 我们还可以设置动态快捷方式的shortLabel的字体颜色。

      ForegroundColorSpan colorSpan = new ForegroundColorSpan(getResources().getColor(android.R.color.holo_red_dark, getTheme()));
      String label = "github";
      SpannableStringBuilder colouredLabel = new SpannableStringBuilder(label);
      colouredLabel.setSpan(colorSpan, 0, label.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
    
      ShortcutInfo webShortcut = new ShortcutInfo.Builder(MainActivity.this, "shortcut_web")
              .setShortLabel(colouredLabel)
              .setRank(1)
              .build();

App Shortcuts Best Practices

当设计和创建应用的shortcuts时,应该遵守下面的指导建议:

  • 遵循设计规范:为了保持我们的应用和系统应用的快捷方式在视觉上一致性,应该遵守App Shortcuts Design Guidelines

  • 发布4个不同的快捷方式:尽管现在的API支持静态和动态总共5个快捷方式,但是为了提高shortcut的视觉效果,建议只添加4个不同的快捷方式。

  • 限制快捷方式描述的文本长度:在Launcher中,显示快捷方式时,空间长度受到了限制。如果可能的话,应该将「short description」的文字长度控制在10个字母以内,将「long discription」的长度限制在25个字母以内。

  • 保存shortcut和action的历史记录:创建的每一个shortcut,应该考虑到用户能够通过不同的方式完成相同的任务。在这种情况下,记得调用 reportShortcutUsed() 方法,这样,launcher就可以提高shortcut对应的actions的反应速度。

  • 只有在shortcuts的意义存在时更新:当改变动态快捷方式时,只有在shortcut仍然保持着它的含义时,调用 updateShortcuts() 方法改变它的信息。否则,应该使用addDynamicShortcuts() 或者 setDynamicShortcuts() 创建一个具有新含义的ID的快捷方式。

    举个例子,如果我们已经创建了导航到一个超市的快捷方式,如果超市的名称改变了但是位置并没有变化时,只更新信息是合适的。但是如果用户开始在一个不同位置的超市购物时,最好是创建一个全新的快捷方式(而不仅仅是更新信息了)。

  • 在备份和恢复时,动态shortcuts不应该被保存:正是因为这个原因,推荐我们在需要APP启动和重新发布动态快捷方式时,检查 getDynamicShortcuts() 的对象的数量。可以参考Backup and Restore部分的代码片段。

Round Icon Resources

在Android 7.1上,Google推出了一个部分用户可能不太喜欢的特性--圆形图标。圆形图标长什么样,可以看看下面的图。


round icon

同时,圆形图标规范也作为一部分内容加入到了更新说明和开发文档中。
应用程序现在可以定义圆形启动器图标以用于特定的移动设备之上。当启动器请求应用程序图标时,程序框架应返回 android:icon 或 android:roundIcon,视设备具体要求而定。因此,应用程序在开发时应该确保同时定义 android:icon和 android:roundIcon 两个变量。您可以使用 Image Asset Studio 来设计圆形图标。

您应该确保在支持新的圆形图标的设备上测试您的应用程序,以确保应用程序图标的外观无虞和实际效果。测试您的资源的一种方法是在 Google Pixel 设备上安装您的应用。您还可以通过运行 Android 模拟器并使用 Google API 模拟器系统(目标 API 等级为 25)测试您的图标。

我们可以通过 Android Studio 自带的 Image Asset Studio设计图标。在项目的 res 目录下点击鼠标右键,选择 new --> Image Asset 即可设计图标。


Image Asset Studio

更多关于设计应用图标的信息,可以参考Material Design guidelines

Image Keyboard Support

在较早版本的Android系统中,软键盘(例如我们所熟知的Input Method Editors,或者说IME),只能够给应用发送unicode编码的emoji,对于rich content,应用只能通过使用自建的私有的API实现发送图片的功能。而在Android 7.1中,SDK包含了一个全新的Commit Content API,输入法应用不仅可以调用此 API 实现发送图片和其他rich content,一些通讯应用(比如 Google Messenger)也可以通过此 API 来更好地处理这些来自输入法的图片、网页信息和 GIF 内容。


image keyboard sample

How it works

  1. 当用户点击EditText时, editor会发送一个它所能接受的 EditorInfo.contentMimeTypes MIME 内容类型的列表。

  2. IME读取这个在软键盘中支持类型和展示内容的列表。

  3. 当用户选择一张图片后,IME调用 commitContent() 并向editor发送一个InputContentInfo。 commitContent() 方法是一个类似于 commitText() 的方法,但是是rich content的。 InputContentInfo 包含着一个表示content provider中内容的URI。然后我们的应用就可以请求相应的权限并读取URI中的内容。


image keyboard diagram

Adding Image Support to Apps

为了接收来自IME的rich content,应用必须告诉IME它所能接收的内容类型并之指定当接收到内容后的回调方法。下面是一个怎样创建一个能够接收PNG图片的 EditText 的演示代码。

EditText editText = new EditText(this) {
    @Override
    public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
        final InputConnection ic = super.onCreateInputConnection(editorInfo);
        EditorInfoCompat.setContentMimeTypes(editorInfo,
                new String [] {"image/png"});

        final InputConnectionCompat.OnCommitContentListener callback =
            new InputConnectionCompat.OnCommitContentListener() {
                @Override
                public boolean onCommitContent(InputContentInfoCompat inputContentInfo,
                        int flags, Bundle opts) {
                    // read and display inputContentInfo asynchronously
                    if (BuildCompat.isAtLeastNMR1() && (flags &
                        InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
                        try {
                            inputContentInfo.requestPermission();
                        }
                        catch (Exception e) {
                            return false; // return false if failed
                        }
                    }

                    // read and display inputContentInfo asynchronously.
                    // call inputContentInfo.releasePermission() as needed.

                    return true;  // return true if succeeded
                }
            };
        return InputConnectionCompat.createWrapper(ic, editorInfo, callback);
    }
};

代码还是蛮多的,解释一下。

  • 例子使用了support library,并且引用的是 android.support.v13.view.inputmethod 而不是 android.view.inputmethod

  • 例子创建了一个 EditText 并复写了它改变 InputConnectiononCreateInputConnection(EditorInfo) 方法. InputConnection 是IME和正在接收输入的沟通管道。

  • 调用 super.onCreateInputConnection() 保留了内建的行为(包括发送和接收文本),并提供给我们一个 InputConnection 的引用。

  • setContentMimeTypes()EditorInfo 添加了一个所支持的MIME类型的列表。 需要保证在 setContentMimeTypes() 之前调用 super.onCreateInputConnection()

  • 回调在IME提交内容是被执行。 onCommitContent() 方法有一个对包含了内容URI的 InputContentInfoCompat 的引用。

    • 当我们的应用运行在API Level 25或者更高并且IME设置了 INPUT_CONTENT_GRANT_READ_URI_PERMISSION flag时,我们应该请求并且释放权限。否则,我们应该在此之前就拥有content URI的访问权限,一是因为权限是由IME授权的,二是content provider不对访问进行约束。更多的信息可以访问Adding Image Support to IMEs
  • createWrapper() 包装了inputConnection和已修改的editorInfo,新的InputConnection的回调并且返回。

下面是一些实践小技巧。

  • 不支持rich content的Editor不应该调用 setContentTypes() 并把 EditorInfo.contentMimeTypes 设置为null。

  • Editor应该忽略掉在 InputConnectionInfo 中指定的MIME类型和所接收类型不通的内容。

  • rich content不影响也不被文本指针的位置所影响。editor在进行内容处理是可以直接忽略掉光标的位置。

  • 在editor的 OnCommitContentListener.onCommitContent() 方法中,我们可以异步的返回true,甚至是在加载内容之前。

  • 不同于文本内容在被提交之前可以在IME中被编辑,rich content会被立即提交。需要注意特性,如果想要提供编辑或者删除内容的能力,我们需要自己提供处理逻辑。

为了测试APP,需要确保你的设备或者虚拟机的键盘能够发送rich content。你可以在Android 7.1或者更高的系统中使用Google Keyboard,或者是安装CommitContent IME sample.

你可以在CommitContent App sample获取到完整的示例代码。

Adding Image Support to IMEs

想要IME支持发送rich content,需要引入下面所展示的Commit Content API。

  • 复写 onStartInput() 或者 onStartInputView() ,并读取来自目标editor的支持内容类型列表。

      @Override
      public void onStartInputView(EditorInfo info, boolean restarting) {
          String[] mimeTypes = EditorInfoCompat.getContentMimeTypes(editorInfo);
    
          boolean gifSupported = false;
          for (String mimeType : mimeTypes) {
              if (ClipDescription.compareMimeTypes(mimeType, "image/gif")) {
                  gifSupported = true;
              }
          }
    
          if (gifSupported) {
              // the target editor supports GIFs. enable corresponding content
          } else {
              // the target editor does not support GIFs. disable corresponding content
          }
      }
  • 当用户选择了一张图片时,将内容提交给APP。当IME有正在编辑的文本时,应该避免调用 commitContent() ,因为这样可能导致editor失去焦点。下面的代码片段展示了怎样提交一张GIF图片。

      /**
       * Commits a GIF image
       *
       * @param contentUri Content URI of the GIF image to be sent
       * @param imageDescription Description of the GIF image to be sent
       */
      public static void commitGifImage(Uri contentUri, String imageDescription) {
          InputContentInfoCompat inputContentInfo = new InputContentInfoCompat(
                  contentUri,
                  new ClipDescription(imageDescription, new String[]{"image/gif"}));
          InputConnection inputConnection = getCurrentInputConnection();
          EditorInfo editorInfo = getCurrentInputEditorInfo();
          Int flags = 0;
          If (android.os.Build.VERSION.SDK_INT >= 25) {
              flags |= InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION;
          }
          InputConnectionCompat.commitContent(
                  inputConnection, editorInfo, inputContentInfo, flags, opts);
      }
  • 作为一个IME开发者,有很大可能你需要引入你自己的content provider来响应content URI请求。如果你的IME支持来自像 MediaStore 这样已经存在的content provider倒是可以例外。关于创建content provider的更多信息,可以参见 CommitContent IME sample, [Content Provider] (developer.android.com/guide%20/to…, File Provider文档。

  • 如果正在创建自己的content provider,建议不要export(将 android:export 设置为false)。通过设置 android:grandUriPermission 为true允许在provider内部进行权限授予替代。然后,你的IME在内容提交时可以授予访问content URI的权限。有两种实现的方法:

    • 在Android 7.1(API Level 25)或更高的系统中,当调用 commitContent 方法时,将flag参数设置为 INPUT_CONTENT_GRANT_READ_URI_PERMISSION 。然后,APP收到的 InputContentInfo 对象可以通过调用 requestPermission() 方法和 releasePermission() 请求和释放临时访问权限。

    • 在Android 7.0(API Level 24)或者更低的系统中, INPUT_CONTENT_GRANT_READ_URI_PERMISSION 直接被忽略,所以我们需要手动的授予内容访问权限。方法就是 grantUriPermission() ,但是我们也可以引入满足自己要求的机制。

权限授予的例子,我们可以在CommitContent IME sample中的doCommitContent()方法。

为了测试IME,确保我们的设备或者模拟器拥有接收rich content的的应用。我们可以在Android 7.1或者更高的系统中使用Google Messenger应用或者安装CommitContent App Sample

获取完整的示例代码,可以访问CommitContent IME Sample

Summary

Google在刷新版本号的路上简直是在策马奔腾了,嘚儿驾。我们也能够看到Google的努力,Android也在变的越来越好,加油吧,小机器人。

本次Shortcuts部分的代码可以在我的GitHub仓库ZhiHuDaily中看到。欢迎star哟。