方法指南

推出 Cahier:一款新的 Android GitHub 示例,可用于大屏设备上的高效工作和创意发挥

阅读用时:11 分钟
查看 Chris Assigbe 的个人资料
Chris Assigbe 开发者关系工程师

Ink API 现已进入 Beta 版阶段,可随时集成到您的应用中。这一里程碑的实现得益于开发者提供的宝贵反馈,这些反馈促使我们不断改进 API 的性能、稳定性和视觉质量。

Google 应用(例如 Google 文档Pixel StudioGoogle 相册Chrome PDFYouTube 特效工坊)以及 Android 上的独特功能(例如圈定即搜)都使用最新的 API。

为庆祝这一里程碑,我们很高兴地宣布推出 Cahier,这是一款全面的记事应用示例,针对各种尺寸的 Android 设备(尤其是平板电脑和可折叠手机)进行了优化。

什么是 Cahier?

Cahier(法语中的“笔记本”)是一款示例应用,旨在演示如何构建应用,让用户能够通过组合文字、绘画和图片来记录和整理自己的想法。

此示例可作为在大屏设备上提高用户工作效率和创意能力的参考。它展示了打造此类体验的最佳实践,有助于开发者更快地了解和采用相关的强大 API 和技术。本文将介绍 Cahier 的核心功能、关键 API 以及使该示例成为您自己的应用的绝佳参考的架构决策。

示例中演示的主要功能包括:

  • 多功能记事创建:展示如何实现灵活的内容创建系统,该系统支持在单个记事中添加多种格式的内容,包括文本、自由绘制的图形和图片附件。
  • 创意墨迹工具:使用 Ink API 实现高性能、低延迟的绘图体验。该示例提供了一个实用示例,展示了如何集成各种画笔、颜色选择器、撤消/重做功能和橡皮擦工具。
  • 通过拖放操作实现流畅的内容集成:演示如何使用拖放操作处理传入和传出的内容。这包括接受从其他应用拖放的图片,以及允许用户将内容拖出您的应用以实现无缝分享。
  • 整理笔记:将笔记标记为收藏,以便快速访问。过滤视图,让一切井井有条。
  • 离线优先架构:采用Room 以离线优先架构构建,确保所有数据都保存在本地,并且应用在没有互联网连接的情况下也能保持完整的功能。
  • 强大的多窗口模式和多实例支持:展示了如何支持多实例,让您的应用可以在多个窗口中启动,以便用户并排处理不同的笔记,从而在大屏幕上提高工作效率和创造力。
  • 适用于所有屏幕的自适应界面:用户界面使用 ListDetailPaneScaffold 和 NavigationSuiteScaffold 无缝适应不同的屏幕尺寸和方向,从而在手机、平板电脑和可折叠设备上提供经过优化的用户体验。
  • 深度系统集成:提供有关如何通过响应系统范围的“记事”intent,使您的应用成为 Android 14 及更高版本上的默认记事应用,从而能够从各种系统入口点快速捕获内容的指南。

专为大屏设备上的高效工作和创意挥洒而打造

在首次发布时,我们将重点介绍一些核心功能,这些功能使 Cahier 成为提高工作效率和激发创造力的重要学习资源。

自适应的基础

Cahier 从一开始就设计为自适应。此示例利用 material3-adaptive 库中的 ListDetailPaneScaffold 和 NavigationSuiteScaffold,可将应用布局无缝调整为适应各种屏幕尺寸和屏幕方向。对于现代 Android 应用而言,这是一个至关重要的元素,而 Cahier 提供了一个清晰的示例,展示了如何有效地实现这一元素。

使用 Material 3 自适应库构建的 Cahier 自适应界面。.gif

使用 Material 3 自适应库构建的 Cahier 自适应界面

展示关键 API 和集成

此示例重点展示了您可以在自己的应用中使用的强大高效的 API,包括:

重点 API 详解

接下来,我们来深入了解 Cahier 集成的两个核心 API,它们可提供一流的记笔记体验。

使用 Ink API 打造自然的墨迹书写体验

触控笔输入可将大屏设备变成数字笔记本和速写本。为了帮助您打造流畅自然的墨迹书写体验,我们已将 Ink API 作为示例的核心。借助 Ink API,您可以轻松创建、渲染和操控精美的墨迹笔画,并获享出色的低延迟体验。

Ink API 采用模块化架构,因此您可以根据应用的特定堆栈和需求对其进行定制。API 模块包括:

  • 创作模块 (Compose视图):处理实时墨迹输入,以尽可能低的延迟时间创建平滑的笔画。
    • 在 DrawingSurface 中,Cahier 使用新引入的 InProgressStrokes 可组合项来处理实时触控笔或触控输入。此模块负责捕获指针事件,并以尽可能低的延迟渲染湿墨笔画。
  • 笔画模块:表示墨水输入及其视觉表示形式。当用户完成线条绘制后,onStrokesFinished 回调会向应用提供最终的/干的 Stroke 对象。这个表示已完成墨迹笔画的不可变对象随后会在 DrawingCanvasViewModel 中进行管理。
  • 渲染模块:可高效显示墨迹笔画,并允许将墨迹笔画与 Jetpack Compose 或 Android 视图相结合。
  • 笔刷模块 (Compose视图):提供一种声明式方式来定义笔画的视觉样式。自 alpha03 版本发布以来,最近的更新包括新增了虚线笔刷,该笔刷对于套索选择等功能特别有用。DrawingCanvasViewModel 存储 currentBrush 的状态。DrawingCanvas 中的工具箱允许用户选择不同的笔刷系列(例如 StockBrushes.pressurePen() 或 StockBrushes.highlighter())并更改颜色。ViewModel 会更新 Brush 对象,然后 InProgressStrokes 可组合项会使用该对象来绘制新笔画。
  • 几何模块 (Compose视图):支持对笔画进行操作和分析,以实现擦除和选择等功能。
    • 工具箱中的橡皮擦工具和 DrawingCanvasViewModel 中的功能依赖于几何模块。当橡皮擦处于活动状态时,它会在用户手势的路径周围创建一个 MutableParallelogram。然后,橡皮擦会检查形状与现有笔画的边界框之间是否存在相交,以确定要擦除哪些笔画,从而使橡皮擦感觉直观而精确。
  • 存储模块:为墨迹数据提供高效的序列化和反序列化功能,从而显著节省磁盘和网络空间。为了保存绘制内容,Cahier 会在其 Room 数据库中持久保存 Stroke 对象。在转换器中,该示例使用存储模块的 encode 函数将 StrokeInputBatch(原始点数据)序列化为 ByteArray。字节数组以及笔刷属性会保存为 JSON 字符串。解码函数用于在加载笔记时重建笔画。
orion.png

除了这些核心模块之外,最近的更新还扩展了 Ink API 的功能:

  • 借助用于自定义 BrushFamily 对象的新实验性 API,开发者可以创建富有创意且独特的笔刷类型,从而为铅笔激光笔等工具提供更多可能性。

Cahier 利用自定义笔刷(包括下面展示的独特音乐笔刷)来展示高级创意可能性。

使用 Ink API 的自定义笔刷创建的彩虹激光..gif

使用 Ink API 的自定义笔刷创建的彩虹激光

notes.png

使用 Ink API 的自定义笔刷创建的音乐笔刷

  • 原生 Jetpack Compose 互操作性模块可直接在您的 Compose 界面中集成墨迹书写功能,从而提供更惯用且更高效的开发体验。

Ink API 具有多项优势,使其成为效率和创意应用的理想选择,优于自定义实现:

  • 易用性:Ink API 可抽象化图形和几何的复杂性,让您专注于 Cahier 的核心功能。
  • 性能:内置的低延迟支持和优化的渲染可确保流畅且响应迅速的墨迹书写体验。
  • 灵活性:模块化设计让您可以选择所需的组件,从而将 Ink API 无缝集成到 Cahier 的架构中。

Ink API 已被许多 Google 应用采用,包括用于 Google 文档中的标记、圈定即搜功能以及 Orion NotesPDF Scanner 等合作伙伴应用。

“Ink API 是我们实现圈定即搜 (CtS) 功能的首选。借助其详尽的文档,我们轻松集成了 Ink API,仅在一周内就完成了第一个可运行的原型。Ink 的自定义笔刷纹理和动画支持功能让我们能够快速迭代笔画设计。” - Jordan Komoda,软件工程师 - Google

成为具有记事角色的默认记事应用

记事是一项核心功能,可提高用户在大屏设备上的效率。借助“注释”角色功能,用户可以在锁定的屏幕上或在其他应用运行时访问您的兼容应用。此功能可识别并设置系统范围的默认记事应用,并授予这些应用启动权限以捕获内容。

Cahier 中的实现

实现注释角色涉及几个关键步骤,所有这些步骤都在示例中进行了演示:

  1. 清单声明:首先,应用必须声明其处理记事 intent 的能力。在 AndroidManifest.xml 中,Cahier 包含 android.intent.action.CREATE_NOTE 操作的 <intent-filter>。这会向系统发出信号,表明该应用是“记事”角色的潜在候选应用。
  2. 检查角色状态SettingsViewModel 使用 Android 的 RoleManager 来确定当前状态。SettingsViewModel 会检查设备上是否有笔记角色 (isRoleAvailable),以及 Cahier 当前是否持有该角色 (isRoleHeld)。此状态通过 Kotlin Flow 向界面公开。
  3. 请求角色:在 Settings.kt 文件中,如果角色可用但未被持有,系统会向用户显示一个按钮。当用户点击该按钮时,系统会调用 ViewModel 中的 requestNotesRole 函数。该函数会创建一个 intent 来打开默认应用设置界面,用户可以在该界面中选择 Cahier。该流程使用 rememberLauncherForActivityResult API 进行管理,该 API 可处理启动 intent 和接收结果。
  4. 更新界面:用户从设置界面返回后,ActivityResultLauncher 回调会触发 ViewModel 中的一个函数来更新角色状态,确保界面准确反映应用是否已成为默认应用。

如需了解如何在应用中集成注释角色,请参阅我们的创建记事应用指南

helloworld.png

在联想平板电脑上,Cahier 在浮动窗口中作为默认记事应用启动

重大进步:联想启用“记事”角色

我们很高兴地宣布,大屏 Android 设备的办公效率将迎来重大提升:Lenovo 已在搭载 Android 15 及更高版本的平板电脑上启用对 Notes Role 的支持!在此次更新后,您可以更新记事应用,以便兼容的 Lenovo 设备的用户将其设置为默认应用,从而在锁屏状态下顺畅访问应用,并解锁系统级内容捕获功能。

这一承诺来自一家领先的 OEM,表明记事功能在 Android 上提供真正集成且高效的用户体验方面正变得越来越重要。

多实例、多窗口模式和窗口化模式

在大屏幕上提高工作效率的关键在于高效管理信息和工作流程。因此,Cahier 充分利用了 Android 的高级窗口功能,可提供灵活的工作空间,满足用户需求。该应用支持:

  • 多窗口:能够在分屏模式或自由窗口模式下与另一个应用并排运行的基本功能。这对于在 Cahier 中做笔记时引用网页等任务至关重要。
  • 多实例:这是真正体现多任务处理优势的地方。借助 Cahier,用户可以同时打开应用的多个独立窗口。想象一下,您可以并排比较两份不同的笔记,或者在一个窗口中参考文字笔记,同时在另一个窗口中处理绘图。Cahier 演示了如何管理这些各自具有不同状态的单独实例,从而将您的应用变成一个功能强大的多面工具。
  • 窗口化模式:连接到外接显示屏时,Android 桌面模式可将平板电脑或可折叠设备转换为工作站。由于 Cahier 采用自适应界面设计,并支持多实例,因此该应用在此环境中表现出色。用户可以像在传统桌面设备上一样打开、调整大小和定位多个 Cahier 窗口,从而实现之前在移动设备上无法完成的复杂工作流程。
cahier-desktop-windowing.webp

在 Pixel Tablet 上以桌面窗口模式运行的 Cahier

以下是我们如何在 Cahier 中实现这些功能的:

为了启用多实例,我们首先需要在 AndroidManifest 中向 MainActivity 的声明添加 PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI 属性,以向系统表明应用支持多次启动:

<activity

    android:name="com.example.cahier.MainActivity"

    android:exported="true"

    android:label="@string/app_name"

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

    android:showWhenLocked="true"

    android:turnScreenOn="true"

    android:resizeableActivity="true"

    android:launchMode="singleInstancePerTask">


    <property

        android:name="android.window.PROPERTY_SUPPORTS_MULTI_INSTANCE_SYSTEM_UI"

        android:value="true"/>

    ...

</activity>

接下来,我们实现了启动应用新实例的逻辑。在 CahierHomeScreen.kt 中,当用户选择在新窗口中打开笔记时,我们会创建一个包含特定标志的新 Intent,以指示系统如何处理新的 activity 启动。FLAG_ACTIVITY_NEW_TASKFLAG_ACTIVITY_MULTIPLE_TASK 和 FLAG_ACTIVITY_LAUNCH_ADJACENT 的组合可确保笔记在新窗口中打开,与现有窗口并排显示。

fun openNewWindow(activity: Activity?, note: Note) {

    val intent = Intent(activity, MainActivity::class.java)

    intent.putExtra(AppArgs.NOTE_TYPE_KEY, note.type)

    intent.putExtra(AppArgs.NOTE_ID_KEY, note.id)

    intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK or

        Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT


    activity?.startActivity(intent)

}

为了支持多窗口模式,我们需要通过设置清单的 <activity><application> 元素向系统表明应用支持调整大小。

<activity

    android:name="com.example.cahier.MainActivity"

    android:resizeableActivity="true"

    ...>

</activity>

界面本身是使用 Material 3 自适应库构建的,因此可以在多窗口模式(例如 Android 的分屏模式)中无缝适应。

为了提升用户体验,我们添加了对拖放的支持。请参阅下文,了解我们如何在 Cahier 中实现此功能。

拖放

真正高效或富有创意的应用不会孤立运行,而是会与设备的其余生态系统无缝互动。拖放是这种互动的基础,尤其是在大屏设备上,用户经常需要在多个应用窗口之间进行操作。Cahier 全面采用这种方式,通过直观的拖放功能来添加和分享内容。

  • 轻松导入:用户可以从其他应用(例如网络浏览器、图库或文件管理器)中拖动图片,然后将其直接放到笔记画布上。为此,Cahier 使用 dragAndDropTarget 修饰符来定义放置区,检查兼容的内容(例如 image/*),并处理传入的 URI。
  • 轻松分享:Cahier 中的内容与其他应用中的内容一样,可以轻松分享。用户可以长按文本记事中的图片,或长按绘图记事和图片合成的整个画布,然后将其拖动到其他应用。

技术层面的深入探讨:从绘图画布拖动

在绘图画布上实现拖动手势是一项独特的挑战。在我们的 DrawingSurface 中,处理实时绘制输入(Ink API 的 InProgressStrokes)的可组合项和检测长按手势以启动拖动的 Box 是 同级可组合项

默认情况下,Jetpack Compose 指针输入系统设计为仅有一个同级可组合项(声明顺序中与触摸位置重叠的第一个)接收事件。在 Cahier 的示例中,我们希望拖放输入处理逻辑有机会在 InProgressStrokes 可组合项使用所有未消耗的输入进行绘制并消耗该输入之前运行并可能消耗输入。如果我们不按正确的顺序排列这些内容,Box 就不会检测到长按手势来开始拖动,或者 InProgressStrokes 就不会收到绘制的输入内容。

为了解决这个问题,我们创建了一个自定义的 pointerInputWithSiblingFallthrough 修饰符,并在可组合项代码中将使用该修饰符的 Box 放在 InProgressStrokes 之前。此实用程序是标准 pointerInput 系统的一个精简封装容器,但有一个关键更改:它会替换 sharePointerInputWithSiblings() 函数以返回 true。这会告知 Compose 框架允许指针事件传递到同级可组合项,即使在被使用后也是如此。

internal fun Modifier.pointerInputWithSiblingFallthrough(

    pointerInputEventHandler: PointerInputEventHandler

) = this then PointerInputSiblingFallthroughElement(pointerInputEventHandler)


private class PointerInputSiblingFallthroughModifierNode(

    pointerInputEventHandler: PointerInputEventHandler

) : PointerInputModifierNode, DelegatingNode() {


    var pointerInputEventHandler: PointerInputEventHandler

        get() = delegateNode.pointerInputEventHandler

        set(value) {

            delegateNode.pointerInputEventHandler = value

        }


    val delegateNode = delegate(

        SuspendingPointerInputModifierNode(pointerInputEventHandler)

    )


    override fun onPointerEvent(

        pointerEvent: PointerEvent,

        pass: PointerEventPass,

        bounds: IntSize

    ) {

        delegateNode.onPointerEvent(pointerEvent, pass, bounds)

    }


    override fun onCancelPointerInput() {

        delegateNode.onCancelPointerInput()

    }


    override fun sharePointerInputWithSiblings() = true

}


private data class PointerInputSiblingFallthroughElement(

    val pointerInputEventHandler: PointerInputEventHandler

) : ModifierNodeElement<PointerInputSiblingFallthroughModifierNode>() {


    override fun create() = PointerInputSiblingFallthroughModifierNode(pointerInputEventHandler)


    override fun update(node: PointerInputSiblingFallthroughModifierNode) {

        node.pointerInputEventHandler = pointerInputEventHandler

    }


    override fun InspectorInfo.inspectableProperties() {

        name = "pointerInputWithSiblingFallthrough"

        properties["pointerInputEventHandler"] = pointerInputEventHandler

    }

}

以下是 DrawingSurface 中的使用方式:

Box(

    modifier = Modifier

        .fillMaxSize()

        // Our custom modifier enables this gesture to coexist with the drawing input.

        .pointerInputWithSiblingFallthrough {

            detectDragGesturesAfterLongPress(

                onDragStart = { onStartDrag() },

                onDrag = { _, _ -> /* consume drag events */ },

                onDragEnd = { /* No action needed */ }

            )

        }

) 

// The Ink API's composable for live drawing sits here as a sibling.

InProgressStrokes(...)

这样一来,系统便可同时正确检测到绘制笔画和长按拖动手势。拖动操作开始后,我们使用 FileProvider 创建可分享的 content:// URI,并使用 view.startDragAndDrop() 将该 URI 传递给系统的拖放框架。此解决方案可确保提供稳健直观的用户体验,并展示了如何在分层界面中克服复杂的手势冲突。

采用现代建筑风格

除了特定 API 之外,Cahier 还展示了用于构建高质量自适应应用的关键架构模式。

表示层:Jetpack Compose 和自适应性

呈现层完全使用 Jetpack Compose 构建。如上所述,Cahier 采用 material3-adaptive 库来实现界面自适应。状态管理遵循严格的单向数据流 (UDF) 模式,其中 ViewModel 实例用作数据容器,用于保存笔记信息和界面状态。

数据层:代码库和 Room

对于数据层,Cahier 使用 NoteRepository 接口来抽象化所有数据操作。这种设计选择可让应用在本地数据源 (Room) 和潜在的未来远程后端之间轻松切换。编辑笔记等操作的数据传输非常简单:

  1. Jetpack Compose 界面会触发 ViewModel 中的方法。
  2. ViewModel 从 NoteRepository 中提取笔记,处理逻辑,并将更新后的笔记传递回代码库。
  3. NoteRepository 会将更新保存到 Room 数据库。

全面的输入支持

若要成为真正的效率利器,应用必须能够完美处理各种输入法。Cahier 旨在符合大屏设备输入指南,并支持:

  • 触控笔:与 Ink API 集成、手掌拒斥、注册记事角色、在文本字段中进行触控笔输入以及沉浸模式。
  • 键盘:支持最常见的键盘快捷键和组合键(例如 Ctrl+点击、Meta+点击),并清晰指示键盘焦点。
  • 鼠标和触控板:支持右键点击和悬停状态。

支持高级键盘、鼠标和触控板互动是进一步改进的关键重点。

立即开始使用

我们希望 Cahier 能成为您开发下一款出色应用的起点。我们将其打造为一款全面的开源资源,旨在展示如何将自适应界面、强大的 API(例如 Ink 和 Notes 角色)以及现代自适应架构相结合。

准备好开始了吗?

  • 探索代码:前往我们的 GitHub 代码库,探索 Cahier 代码库并了解设计原则的实际应用。
  • 自行构建:将 Cahier 用作您自己的记笔记、文档标记或创意应用的基础。
  • 贡献:我们欢迎您贡献数据!帮助我们让 Cahier 成为 Android 开发者社区更出色的资源。

不妨查看官方开发者指南,立即开始打造下一代提高工作效率和创造力的应用。我们迫不及待地想看看你的作品!

编剧:
继续阅读