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

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来体验。)

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

长按图标,收到震动后松手,如果能够看到图标上弹出了支持的跳转操作,说明成功的呼出了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推出了一个部分用户可能不太喜欢的特性--圆形图标。圆形图标长什么样,可以看看下面的图。

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

同时,圆形图标规范也作为一部分内容加入到了更新说明和开发文档中。

应用程序现在可以定义圆形启动器图标以用于特定的移动设备之上。当启动器请求应用程序图标时,程序框架应返回 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 即可设计图标。

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

更多关于设计应用图标的信息,可以参考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 内容。

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

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中的内容。

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

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] (https://developer.android.com...文档, 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哟。

相关推荐