8 用户反馈
你通常可以通过向用户展示更多关于正在发生的事情的信息来提高应用程序的可用性。这可能表现为当输入不合理时显示更好的消息,或者对需要很长时间的操作使用进度条。一些反馈是通过输出自然发生的,你已经知道如何使用它们,但你经常还需要其他的东西。本章的目标是向你展示一些其他的选择。
我们将从验证
技术开始,在输入(或输入组合)处于无效状态时通知用户。然后我们将继续讨论通知
,向用户发送一般消息,以及进度条
,这些为包含许多小步骤的耗时操作提供了详细信息。最后,我们将讨论危险操作,以及如何使用确认
对话框或撤销
操作的能力来让你的用户安心。
在本章中,我们将使用Andy Merlino的shinyFeedback和John Coene的waiter。你还应该关注Joe Cheng正在开发的shinyvalidate包。
1 | library(shiny) |
8.1 验证
你可以给用户提供的第一个也是最重要的反馈是,他们的输入有误。这与在R中编写好函数类似:用户友好的函数会给出清晰的错误消息,描述期望的输入是什么,以及你是如何违背这些期望的。思考用户可能会如何误用你的应用程序,可以让你在用户界面(UI)中提供有用的消息,而不是让错误渗透到R代码中并产生无用的错误信息。
8.1.1 验证输入
通过shinyFeedback包向用户提供额外的反馈是一个很好的方法。使用它分为两步。首先,你需要在用户界面(ui)中添加useShinyFeedback()。这将为显示美观的错误消息设置所需的HTML和JavaScript:
1 | ui <- fluidPage( |
然后在您的server()函数中,您调用反馈函数之一:feedback()、feedbackWarning()、feedbackDanger()和feedbackSuccess()。他们都有三个关键参数:
inputId
,放置反馈的输入的id。show
,一个逻辑值,确定是否显示反馈。text
,要显示的文本。
它们还有颜色和图标参数,你可以使用它们进一步自定义外观。更多详细信息请查阅文档。
让我们看看这在一个真实例子中是如何实现的,假设我们只允许输入偶数。图8.1显示了结果。
1 | half <- reactive({ |
请注意,错误消息已经显示,但输出仍然更新了。通常,你不想这样做,因为无效的输入可能会引发你不希望显示给用户的无用的R错误。要阻止输入触发响应式更改,你需要一个新工具:req(),即“required”的缩写。它的用法如下:
1 | server <- function(input, output, session) { |
当req()
的输入不为真时,它会发送一个特殊信号,告诉Shiny这个响应式组件没有它所需的所有输入,因此应该“暂停”。在将其与validate()
结合使用之前,我们先简要讨论一下这个问题。
8.1.2 使用req()
取消执行
通过跳出验证的范畴,可以更容易地理解req()。你可能已经注意到,当你启动一个应用程序时,即使用户还没有进行任何操作,完整的响应式图也会被计算出来。当你能够为输入选择有意义的默认值
时,这可以很好地工作。但这并不总是可能的,有时你可能希望等到用户实际执行了某些操作再进行计算。这种情况通常出现在以下三种控件中:
在textInput()中,你使用了
value = ""
,并且不希望用户输入任何内容之前执行任何操作。在selectInput()中,你提供了一个空选项
""
,并且不希望用户进行选择之前执行任何操作。在fileInput()中,用户在上传任何内容之前,其结果都是空的。我们将在
9.1
节中再次讨论这个问题。
我们需要一种方法来“暂停”响应式组件,以便在满足某个条件之前不会发生任何事情。这就是req()
的工作,它会在允许响应式生产者继续之前检查所需的值。
例如,考虑以下应用程序,它将生成英语或毛利语的问候语。如果你运行这个应用程序,你会看到一个错误,如图8.2
所示,因为在greetings
向量中没有与默认选项""
相对应的条目。
1 | ui <- fluidPage( |
我们可以使用req()
来解决这个问题,如下所示。现在,在用户为语言和姓名都提供了值之前,将不会显示任何内容,如图8.3
所示
1 | server <- function(input, output, session) { |
req()
通过发出一个特殊条件
来工作。这个特殊条件导致所有下游的响应式组件和输出停止执行。从技术上讲,它使任何下游的响应式消费者处于无效状态。我们将在第16
章中再次回到这个术语。
req()
的设计是使得req(inputx)
只有在用户提供了值的情况下才会继续执行,而不考虑输入控件的类型。如果需要,你也可以使用req()
与自己的逻辑语句结合使用。例如,req(inputa > 0)
会在a
大于0时允许计算继续进行;这通常是我们执行验证时使用的形式,我们将在下面看到。
8.1.3 req()
和验证
让我们将req()
和shinyFeedback结合起来,解决一个更具挑战性的问题。我将回到我们在第1章中制作的简单应用程序,该应用程序允许你选择内置数据集并查看其内容。我将通过使用textInput()
而不是selectInput()
来使其更加通用和复杂。用户界面几乎没有变化:
1 | ui <- fluidPage( |
但是服务器函数需要变得稍微复杂一些。我们将以两种方式使用req()
:
我们只想在用户输入了值之后才进行计算,所以我们使用
req(input$dataset)
。然后,我们检查提供的名称是否确实存在。如果不存在,我们将显示一条错误消息,然后使用
req()
取消计算。请注意cancelOutput = TRUE
的使用:通常取消响应式组件会重置所有下游输出;使用cancelOutput = TRUE
会使它们保持显示上一次的有效值。这对于textInput()
来说很重要,因为您可能在输入名称的过程中触发更新。
结果如图8.4
所示。
1 | server <- function(input, output, session) { |
8.1.4 验证输出
当问题与单个输入相关时,shinyFeedback非常有用。但有时无效状态是多个输入组合的结果。在这种情况下,将错误放在输入旁边并没有太大意义(应该放在哪个输入旁边呢?),相反,将其放在输出中更有意义。
您可以使用shiny内置的一个工具来实现:validate()。当在响应式组件或输出中调用时,validate(message)
会停止其余代码的执行,并在任何下游输出中显示message
。以下代码显示了一个简单示例,其中我们不想记录或取负值的平方根。您可以在图8.5
中查看结果。
1 | ui <- fluidPage( |
8.2 通知
如果没有问题,您只是想让用户知道发生了什么,那么您需要一个通知
。在Shiny中,通知是通过showNotification()创建的,并堆叠在页面的右下角。使用showNotification()
有三种基本方式:
显示一个临时通知,该通知在固定时间后自动消失。
在进程开始时显示通知,并在进程结束时将其移除。
使用渐进更新来更新单个通知。
下面将讨论这三种技术。
8.2.1 临时通知
使用showNotification()
的最简单方法是只传入一个参数:您想要显示的消息。由于很难通过截图捕捉这种行为,所以如果您想看到它的实际效果,请访问https://hadley.shinyapps.io/ms-notification-transient。
1 | ui <- fluidPage( |
默认情况下,消息将在5秒后消失,你可以通过设置duration
来覆盖这个时间,或者用户可以点击关闭按钮提前关闭它。如果你想让通知更加醒目,你可以将type
参数设置为“message”、“warning”或“error”其中之一。图8.6
展示了这些通知的外观。
1 | server <- function(input, output, session) { |
8.2.2 完成时移除
将通知的存在与一个长时间运行的任务关联起来通常很有用。在这种情况下,你希望在任务开始时显示通知,并在任务完成时移除通知。为此,你需要:
设置
duration = NULL
和closeButton = FALSE
,以确保通知在任务完成之前一直保持可见状态。保存showNotification()返回的id,然后将其传递给removeNotification()。最可靠的方法是使用on.exit(),这可以确保无论任务如何完成(成功或出错),通知都会被移除。你可以在“更改和恢复状态”一章中了解更多关于
on.exit()
的信息。
以下示例将各个部分组合在一起,展示了如何在读取大型csv文件时向用户更新状态:
1 | server <- function(input, output, session) { |
一般来说,这类通知会存在于一个响应式环境中,因为这可以确保只有当需要时,长时间运行的计算才会重新执行。
8.2.3 渐进式更新
正如你在第一个示例中所看到的,多次调用showNotification()通常会创建多个通知。相反,你可以通过捕获第一次调用的id并在后续调用中使用它来更新单个通知。如果你的长时间运行的任务包含多个子组件,这将非常有用。你可以在https://hadley.shinyapps.io/ms-notification-updates查看结果。
1 | ui <- fluidPage( |
8.3 进度条
对于长时间运行的任务,最好的反馈形式就是进度条。它不仅可以告诉你任务进行到了哪一步,还可以帮助你估计还需要多长时间:你是应该深吸一口气,去喝杯咖啡,还是明天再来?在本节中,我将展示两种显示进度条的技术,一种是Shiny内置的,另一种是John Coene开发的waiter包提供的。
不幸的是,这两种技术都存在同样的主要缺点:要使用进度条,你需要能够将大任务分成已知数量的、每个都大致需要相同时间来完成的小任务。这通常很难做到,特别是当底层代码是用C语言编写的,且没有办法向你传达进度更新时。我们正在努力开发progress包中的工具,以便有一天像dplyr、readr和vroom这样的包能够生成进度条,你可以轻松地将它们转发给Shiny。
8.3.1 Shiny
要使用Shiny创建进度条,你需要使用withProgress()和incProgress()。假设你有一段运行缓慢的代码,如下所示:
1 | for (i in seq_len(step)) { |
你首先需要用withProgress()
将其包裹起来。当代码开始运行时,这会显示进度条,并在完成时自动移除它:
1 | withProgress({ |
然后在每个步骤之后调用incProgress()
:
1 | withProgress({ |
incProgress()
的第一个参数是进度条要增加的量。默认情况下,进度条从0开始,到1结束,因此通过步骤数除以来增加进度条,可以确保循环结束时进度条完成。
图8.7
展示了在一个完整的Shiny应用程序中,这可能看起来是什么样子。
1 | ui <- fluidPage( |
需要注意的几个事项:
我使用了可选的
message
参数,在进度条中添加了一些解释性文本。我使用了Sys.sleep()来模拟一个长时间运行的操作;在你的代码中,这将是一个耗时函数。
我通过将一个按钮与eventReactive()结合,允许用户控制事件何时开始。这是任何需要进度条的任务的良好做法。
8.3.2 Waiter
内置的进度条对于基础操作来说非常好,但如果你想要提供更多视觉选项的进度条,你可以尝试使用waiter包。将上面的代码适配为与Waiter一起工作很简单。在UI中,我们添加use_waitress():
1 | ui <- fluidPage( |
Waiter的进度条接口略有不同。Waiter包使用R6对象将所有与进度相关的函数捆绑到一个单独的对象中。如果你以前从未使用过R6对象,不必太担心细节;你可以直接复制和粘贴这个模板。基本的生命周期看起来像这样:
1 | # Create a new progress bar |
我们可以在 Shiny 应用程序中使用它,如下所示:
1 | server <- function(input, output, session) { |
默认显示是在页面顶部的细长进度条(你可以查看https://hadley.shinyapps.io/ms-waiter),但有许多方法可以自定义输出:
你可以覆盖默认主题
,选择使用以下主题之一:
overlay
:一个不透明的进度条,会隐藏整个页面overlay-opacity
:一个透明的进度条,会覆盖整个页面overlay-percent
:一个不透明的进度条,同时还会显示一个数字百分比。- 除了为整个页面显示进度条之外,你还可以通过设置
selector
参数将其覆盖在现有的输入或输出上,例如:
1 | waitress <- Waitress$new(selector = "#steps", theme = "overlay") |
8.3.3 Spinners
有时,你不知道一个操作需要多长时间才能完成,你只想显示一个动画旋转器,让用户知道有事情正在发生。对于这个任务,你也可以使用waiter包;只需从使用Waitress
切换到使用Waiter
即可:
1 | ui <- fluidPage( |
与Waitress一样,你也可以针对特定的输出使用Waiters。这些waiter可以在输出更新时自动移除旋转器,因此代码甚至更简单:
1 | ui <- fluidPage( |
waiter包提供了多种旋转器供你选择;你可以在?waiter::spinners中查看你的选项,然后选择其中一个(例如)使用Waiter$new(html = spin_ripple())
。
一个更简单的选择是使用Dean Attali的shinycssloaders包。它使用JavaScript监听Shiny事件,因此甚至不需要服务器端代码。相反,你只需使用shinycssloaders::withSpinner()来包装你希望在其失效时自动获得旋转器的输出。
1 | library(shinycssloaders) |
8.4 确认和撤销
有时某个操作可能具有潜在的危险性,你可能想要确保用户确实想要执行该操作,或者你想要在一切都太晚之前给他们一个撤销的机会。本节中的三个技巧列出了你的基本选项,并给出了一些如何在你的应用程序中实现它们的建议。
8.4.1 明确确认
保护用户避免因误操作执行危险动作的最简单方法是要求用户进行明确的确认。最简单的方法是使用对话框,该对话框会强制用户从一组小动作中选择。在Shiny中,你可以使用modalDialog()创建一个对话框。这被称为“模态”对话框,因为它创建了一种新的交互“模式”;在处理对话框之前,你无法与主应用程序进行交互。
假设你有一个Shiny应用程序,用于从目录中删除一些文件(或从数据库中删除一些行等)。这个操作很难撤销,因此你需要确保用户确实想要这么做。你可以创建一个对话框,如图8.10
所示,要求用户进行明确的确认,如下所示:
1 | modal_confirm <- modalDialog( |
在创建对话框时,需要注意一些虽小但重要的细节:
按钮应该怎么命名?最好描述得具体一些,因此要避免使用“是/否”或“继续/取消”,而应该复述关键的动词。
按钮应该如何排序?你是把“取消”放在前面(像Mac那样),还是把“继续”放在前面(像Windows那样)?你最好的选择是模仿大多数人将使用的平台。
你能让危险选项更明显吗?这里我使用了
class = "btn btn-danger"
来突出显示按钮。
Jakob Nielsen在http://www.useit.com/alertbox/ok-cancel.html上给出了更多好建议。
让我们在一个真实(尽管很简单)的应用程序中使用这个对话框。我们的用户界面暴露了一个“删除所有文件”的按钮:
1 | ui <- fluidPage( |
在server()
中有两个新的想法:
我们使用showModal()和removeModal()来显示和隐藏对话框。
我们观察由UI从
modal_confirm
生成的事件。这些对象不是静态地在ui中创建的,而是由server()
中的showModal()
动态添加的。在第10
章中,你将更详细地看到这个想法。
1 | server <- function(input, output, session) { |
8.4.2 撤消操作
明确的确认对于很少执行且具有破坏性的操作最有用。如果你想要减少因频繁操作而产生的错误,那么应该避免使用它。例如,这种技术不适用于Twitter——如果有一个对话框说“你确定要发这条推文吗?”你会很快学会自动点击“是”,并且在发送推文10秒后注意到拼写错误时,仍然会感到同样的后悔。
在这种情况下,更好的方法是在实际执行操作前等待几秒钟,给用户一个发现并撤销任何问题的机会。这并不是真正的撤销(因为你实际上没有做任何事情),但这是一个让用户能够理解的词汇。
我将用一个我个人希望拥有撤销按钮的网站——Twitter来举例说明这项技术。Twitter用户界面的本质非常简单:有一个文本区域用来编辑推文,还有一个发送按钮:
1 | ui <- fluidPage( |
服务器函数相当复杂,需要一些我们尚未讨论过的技术。不要太担心理解代码,要专注于基本思路:我们使用一些observeEvent()特殊的参数,以便在几秒钟后运行一些代码。主要的新想法是,我们捕获observeEvent()
的结果并将其保存到一个变量中;这样我们就可以销毁观察者,从而确保实际发送推文的代码永远不会被执行。你可以尝试访问实时应用程序https://hadley.shinyapps.io/ms-undo。
1 | runLater <- function(action, seconds = 3) { |
8.4.3 回收站
对于几天后可能会后悔的操作,一种更高级的模式是在计算机上实现类似回收站或垃圾箱的功能。当你删除一个文件时,它并不会被永久删除,而是被移动到一个暂存区,需要执行另一个操作才能清空。这就像是一个强化版的“撤销”选项,你有很长的时间来后悔你的操作。这也有点像确认操作,你需要执行两个分开的操作才能使删除操作永久生效。
这种技术的主要缺点是实现起来相当复杂(你需要有一个单独的“暂存区”来存储撤销操作所需的信息),并且需要用户定期干预以避免信息积累。因此,我认为除了最复杂的Shiny应用程序之外,这种技术都超出了其他应用程序的范围,所以这里我不会展示它的实现方法。
8.5 总结
本章为你提供了一些工具,帮助你向用户传达应用程序正在发生的事情。从某种意义上说,这些技术大多是可选的。虽然你的应用程序没有它们也能工作,但它们的精心应用会对用户体验的质量产生巨大影响。当你是应用程序的唯一用户时,通常可以省略反馈,但使用它的人越多,精心设计的通知就越能发挥作用。
在下一章中,你将学习如何向用户传输文件。
加关注
关注公众号“生信之巅”。
敬告:使用文中脚本请引用本文网址,请尊重本人的劳动成果,谢谢!