Shiny 与交互:从脚本到应用
SAS 程序是"跑完就结束"的一次性批处理;Shiny 应用是"活着等用户操作"的常驻服务。这一章带你把多年积累的 SAS 分析脚本,第一次包装成能点、能选、能实时刷新的交互工具——从心智模型到部署,再到 GxP 环境下绕不开的验证与合规问题。
7.1 心智模型:reactive 依赖图 vs SAS 批处理
在写第一行 Shiny 代码前,请先换一套脑子。SAS 的世界里,一个 DATA step 或 PROC 从头跑到尾,读入数据、算完、写出结果、结束——它是无状态的一次性脚本。Shiny 完全不同:应用启动后会一直挂在内存里,界面上每个控件的每次变化,都会沿着一张响应式依赖图(reactive graph)向下游传播,只重算真正受影响的那部分。
把下面这组对照记在心里,本章后面所有代码都是它的展开:
- 无状态脚本 → 响应式依赖图:SAS 是"从上到下一条流水线跑完即销毁";Shiny 是"节点之间连成一张图,输入变了就顺着边自动重算,算完的结果被缓存复用"。
- 输入 = widget(控件):SAS 里你靠改代码或宏参数
%let arm=X来"输入";Shiny 里用户在浏览器点一个下拉框、拖一个滑块,就是一个input$xxx,它天生是响应式的。 - 输出 = render* 函数:SAS 靠
PROC PRINT/ ODS 把结果写死到 listing;Shiny 靠renderTable()/renderPlot()等把结果"绑"到界面上的一个占位符,源数据一变,输出自动刷新。 - 惰性 vs 急切:SAS 语句写一行执行一行;Shiny 的
reactive()是惰性的——没有人向它要值,它就绝不重算,这正是性能与正确性的关键(7.3 详谈)。
reactive() / observeEvent() 只是"注册"了一条规则,之后由 Shiny 引擎在合适的时机替你反复触发——不是你写一行它就跑一行。7.2 最小 app:一个下拉框过滤受试者
我们从能跑的最小闭环开始:界面提供一个治疗组下拉框(selectInput),下方一张表(tableOutput);用户选组,表格实时过滤。数据用本书一贯的临床示例数据 pharmaversesdtm::dm(SDTM 人口学域,含 ARM/AGE/SEX)。
pharmaversesdtm 提供的 SDTM dm 数据集(306 行),而非 ADaM 的 adsl——当前环境装的是 SDTM 包,没有 ADaM 层。dm 与 adsl 同为受试者级、都带 ARM,用来教交互过滤完全够用;换成你司的 adsl 只需改数据源与变量名。library(shiny)
data("dm", package = "pharmaversesdtm")
# ---- ui:界面 = 输入控件 + 输出占位符 ----
ui <- fluidPage(
selectInput("arm", "选择治疗组 (ARM)",
choices = c("全部" = "ALL", unique(dm$ARM))),
tableOutput("tbl")
)
# ---- server:逻辑 = reactive 过滤 + renderTable 绑定 ----
server <- function(input, output, session) {
filtered <- reactive({ # 惰性:只有 tbl 要值时才重算
d <- dm
if (input$arm != "ALL") d <- d[d$ARM == input$arm, , drop = FALSE]
d[, c("USUBJID", "AGE", "SEX", "ARM")]
})
output$tbl <- renderTable({ head(filtered(), 10) })
}
app <- shinyApp(ui = ui, server = server) # 组装成可运行对象
Shiny 应用无法在批处理里"启动服务器并展示界面",所以本书的验证方式是构造验证:把代码 source 进来,确认 ui / server / app 三个对象类型正确、依赖图能装配成功。下面是 R 4.5.0 的真实捕获:
# 实跑捕获(R 4.5.0,构造验证) class(ui) : shiny.tag.list list class(server): function class(app) : shiny.appobj choices ARM : ALL | Placebo | Xanomeline High Dose | Xanomeline Low Dose | Screen Failure
ui的 class 是shiny.tag.list——说明fluidPage()把控件正确组装成了一棵 HTML 标签树;selectInput的第一个参数"arm"就是稍后input$arm的名字。server是一个普通function,签名固定为function(input, output, session);Shiny 启动时对每个用户会话调用它一次。app的 class 是shiny.appobj——这是shinyApp()把 ui 和 server 焊接后的可运行对象,构造成功即代表依赖图无装配错误。choices那行证明数据真的读进来了、ARM 有 4 个水平外加"全部",下拉框不会是空的。
reactive() 外面直接写 d <- dm[dm$ARM == input$arm, ]。那样只在 server 启动那一瞬执行一次,此时 input$arm 往往还没就绪,且之后永不更新。凡是依赖 input 的计算,必须包进 reactive() 或 render*() 里,才能挂上依赖图。怎么真正跑起来看界面?把上面代码存成一个目录里的 app.R,然后在 R 控制台执行:
# 交互式运行(会占用当前 R 会话,浏览器自动弹出)
shiny::runApp("app.R")
# 或在 RStudio 里打开 app.R,点右上角 "Run App"
# 停止服务:控制台按 Esc,或浏览器关标签页后回 R 里 Ctrl+C
7.3 三件套对照:reactive / reactiveVal / observeEvent
Shiny 的响应式世界由三个基本积木构成。分不清它们是新手最常见的坑,用这张表一次锁死:
| 积木 | 返回值 | 副作用 | 触发时机 | 典型用途 |
|---|---|---|---|---|
reactive({ ... }) | 有——像函数一样被下游 () 取值 | 无(纯计算) | 惰性:有人向它要值且依赖变了才重算 | 过滤后的数据、派生计算,多处共享且缓存 |
reactiveVal(x) | 有——读 v(),写 v(new) | 无 | 被写入时,使所有读它的下游失效 | 需要跨事件手动改的可变状态(计数器、开关) |
observeEvent(trigger, { ... }) | 无——不返回值给下游 | 有:执行动作 | 急切:trigger 一变立即运行 | 点按钮时写状态、弹提示、写日志、下载 |
一句话记忆:reactive 负责"算出值供人用",observeEvent 负责"做一件事",reactiveVal 是二者之间传递状态的可变盒子。
server <- function(input, output, session) {
doubled <- reactive({ input$x * 2 }) # ① 有返回值,惰性纯计算
snapshot <- reactiveVal(NULL) # ② 可变状态盒子
observeEvent(input$go, { snapshot(doubled()) }) # ③ 副作用:点按钮才落盘
output$show <- renderPrint({
list(live_doubled = doubled(), snapshot = snapshot())
})
}
# 实跑捕获(R 4.5.0,构造验证) class(app): shiny.appobj reactive 是函数吗: TRUE
doubled随input$x实时变化——它是活的:输入框一改,任何读doubled()的地方立刻拿到新值。snapshot初值NULL,只有点"记录"按钮才被observeEvent写入当前的 doubled 值——它是被冻结的历史值,不随输入框实时变。- 界面上因此同时显示两个数:
live_doubled(实时)与snapshot(点按钮那一刻的快照)。这个对比能让你彻底体会 reactive 的"惰性实时"与 observeEvent 的"事件触发"之别。 is.reactive()返回 TRUE,印证 reactive 对象本质是一个可被调用的响应式函数。
7.4 modules:用命名空间造可复用组件
当 app 变大,把一块 UI + 一段 server 逻辑打包成模块(module),就能像 SAS 宏一样复用,还能在同一个 app 里放多个互不干扰的实例。现代写法是 NS()(命名空间)+ moduleServer()。下面是一个经典计数器模块,我们在一个页面里放两份,验证它们的状态彼此独立。
library(shiny)
# ---- 模块 UI:所有 id 都要过 NS() 命名空间 ----
counterUI <- function(id) {
ns <- NS(id)
tagList(
actionButton(ns("plus"), "+1"),
textOutput(ns("count"))
)
}
# ---- 模块 Server:moduleServer 封装命名空间 ----
counterServer <- function(id) {
moduleServer(id, function(input, output, session) {
n <- reactiveVal(0)
observeEvent(input$plus, { n(n() + 1) })
output$count <- renderText({ paste("当前计数:", n()) })
reactive({ n() }) # 把内部状态暴露给父级(可选)
})
}
ui <- fluidPage(counterUI("a"), counterUI("b"))
server <- function(input, output, session) {
counterServer("a") # 两个实例,各自的 n 互不影响
counterServer("b")
}
app <- shinyApp(ui = ui, server = server)
# 实跑捕获(R 4.5.0,构造验证)
class(counterUI) : function
class(counterServer) : function
class(ui) : shiny.tag.list list
class(app) : shiny.appobj
NS 命名空间示例: NS('a')('plus') => a-plus
ns <- NS(id)生成一个"加前缀"的函数:ns("plus")实际渲染成a-plus(见输出末行)。这就是命名空间——两个实例的按钮 id 分别是a-plus和b-plus,绝不撞车。moduleServer(id, function(input, output, session){...}):模块内部照常写input$plus,Shiny 自动帮你补上命名空间前缀,你无需在模块里手动拼 id。- UI 端和 Server 端必须用同一个 id(这里都是
"a"/"b"),否则命名空间对不上,控件点了没反应——这是模块最常见的 bug。 counterServer末尾return(reactive({ n() }))让父级能读到子模块的状态,实现模块间通信。
n 和实例 b 的 n 是两个独立对象,不存在全局命名冲突,也不能像宏变量那样互相直接引用——要通信必须通过返回值或共享的 reactiveVal。7.5 DT 与 plotly:让表格和图都能交互
临床数据探索离不开一张能排序、能搜索、能分页的大表,和一张能悬停看数值、能缩放的图。DT 和 plotly 就是干这个的两个 htmlwidget 包。
- DT:
DT::renderDataTable(expr, server = TRUE)里的server = TRUE是一句话关键——把排序/搜索/分页交给 R 服务端逐次处理,而不是一次性把整表塞进浏览器。几万行受试者数据必须用server = TRUE,否则页面会卡死。 - plotly:
plotly::renderPlotly()配ggplotly(),可以把一张静态 ggplot(这里概念上是一张 KM 生存曲线)瞬间变成可悬停、可缩放、可点图例隐藏分组的交互图。
library(shiny); library(DT); library(plotly)
library(survival); library(survminer)
data("dm", package = "pharmaversesdtm")
ui <- fluidPage(
DT::dataTableOutput("tbl"),
plotly::plotlyOutput("km")
)
server <- function(input, output, session) {
# DT:server=TRUE 把大数据的分页/搜索放在服务端
output$tbl <- DT::renderDataTable({
DT::datatable(dm[, c("USUBJID", "AGE", "SEX", "ARM")],
options = list(pageLength = 5))
}, server = TRUE)
# plotly:把 ggplot 的 KM 曲线概念转成交互图
output$km <- plotly::renderPlotly({
fit <- survfit(Surv(time, status) ~ sex, data = lung)
p <- ggsurvplot(fit, data = lung, pval = TRUE)
ggplotly(p$plot) # 注意取 $plot,而非整个 ggsurvplot 对象
})
}
app <- shinyApp(ui = ui, server = server)
# 实跑捕获(R 4.5.0,构造验证) class(app) : shiny.appobj class(datatable) : datatables htmlwidget class(ggplotly(km)) : plotly htmlwidget
DT::datatable()的 class 是datatables htmlwidget,说明它构造出了一个交互表控件;pageLength = 5控制每页行数。ggsurvplot()返回的是一个复合列表(含曲线、风险表、p 值),ggplotly()只吃其中的 ggplot,所以必须写p$plot;直接丢p会报错。ggplotly()的 class 是plotly htmlwidget——静态图成功升级为交互图,构造验证通过。- 这里用
survival::lung演示 KM 概念;实战中把它换成你的 ADSL/ADTTE 时间-事件数据即可,交互骨架不变。
7.6 teal:临床探索应用的现成框架
从零搭一个带筛选面板、代码可追溯、可导出报告的临床探索 app 很费工。teal(pharmaverse 生态)就是为此而生:它把"数据 + 过滤器 + 分析模块"标准化,你用 teal::init() 组装即可。下面的例子把 dm 塞进 teal_data,挂一个示例模块,构造出完整 teal app。
library(teal)
data("dm", package = "pharmaversesdtm")
td <- teal_data(dm = dm) # teal 的数据容器(带代码追溯)
app <- teal::init(
data = td,
modules = modules(
teal::example_module(label = "示例模块", datanames = "dm")
)
)
# 实跑捕获(R 4.5.0,构造验证) class(td) : teal_data datanames : dm class(app) : teal_app list
teal_data(dm = dm)的 class 是teal_data——它不只是个 list,还会记录"这些数据是由哪段代码产生的",为可追溯与复现服务。names(td)得到dm,这就是模块里datanames = "dm"引用的键名,二者必须对得上。teal::init()返回的 class 是teal_app(同时是 list)——它本质仍是一个 Shiny app 对象,可以直接shiny::runApp(app)运行。
teal.slice(筛选器面板)概念:teal 左侧那一列数据过滤器由 teal.slice 包驱动。用户在浏览器里拖滑块、选水平来筛 dm,这些切片状态可以被保存、分享、复现——相当于把 SAS 里手写的一堆 where 条件变成了可视、可版本化的对象。
临床模块扩展:真正的分析模块在 teal.modules.clinical 里——它提供符合临床报告习惯的现成模块(如受试者处置、不良事件、实验室Shift 图等)。把 example_module() 换成其中某个模块,就得到一个可直接给医学审阅用的探索工具。详见 teal.modules.clinical 文档。
7.7 部署:把 app 送到别人能打开的地方
本地 runApp() 只有自己看得到。要让同事在浏览器里打开,最快的路是 shinyapps.io(Posit 托管的免费/付费云)。四步走:
- 装 rsconnect:
install.packages("rsconnect")——这是负责打包上传的客户端包。 - 授权账号:在 shinyapps.io 网站后台复制一段 token,粘进
rsconnect::setAccountInfo(...),把本机与该账号绑定(一次性)。 - 部署:
rsconnect::deployApp("app_dir")——它扫描依赖、打包、上传、在云端重建环境。 - 拿 URL:部署成功后控制台打印一个
https://<账号>.shinyapps.io/<app名>/的公开链接,发给同事即可访问。
# 示例(不要真跑——需要你自己的 shinyapps.io 账号与 token)
install.packages("rsconnect")
library(rsconnect)
rsconnect::setAccountInfo(
name = "your_account",
token = "<从 shinyapps.io 后台复制>",
secret = "<从 shinyapps.io 后台复制>"
)
rsconnect::deployApp("path/to/app_dir") # 上传并返回访问 URL
deployApp() 会尝试解析依赖,但版本可能与本地不一致导致行为漂移。务必用 renv(第 0 章)锁定包版本,并在部署配置里声明 R 版本,否则云端复现结果和你本地对不上——这在验证语境下是硬伤。7.8 GxP 考量:交互应用的验证与合规
Shiny app 一旦触及 GxP 数据或决策,就不再是"写个工具给同事玩玩",而要接受与 SAS 程序同等的验证纪律,甚至更严——因为它是常驻、有状态、可被反复操作的服务。
FDA 21 CFR Part 11(电子记录与电子签名)核心概念:
- 审计轨迹(Audit Trail):谁、在什么时间、把哪个输入从 A 改成了 B、看到了什么输出——必须被系统安全、带时间戳地记录且不可篡改。原生 Shiny 不带审计轨迹,需你自己在 server 里对关键操作写日志(配合
logger落盘)。 - 电子签名(e-Signature):若 app 承载"审阅批准"动作,签名必须唯一绑定到个人、含签名时间/含义,且不可被复制或伪造——通常需要接入企业身份系统而非在 app 里自造。
验证策略(把 SAS 的 CSV 心智平移过来):
| 验证维度 | 做法 | 对应 SAS 世界 |
|---|---|---|
| 输入边界测试 | 用 shinytest2 模拟极端输入:空选择、超范围滑块、非法字符,断言 app 不崩且给出正确提示 | 宏参数的边界与异常处理测试 |
| 输出快照测试 | 固定输入下对表格/图做快照(screenshot / 数据结构),版本迭代时比对,防回归 | PROC COMPARE 比对基线输出 |
| 版本锁定 | renv 锁包版本 + Git 锁代码 + 镜像锁 R 版本,三者构成可复现证据链 | 冻结 SAS 版本与宏库 |
测验:reactive() 与 observeEvent() 的核心区别是什么?
reactive() 是惰性纯计算,像函数一样被下游 () 取值并缓存;observeEvent() 不返回值给下游,而是在触发条件满足时急切地"做一件事"(写状态、弹提示、写日志)。D 恰好说反了——缓存是 reactive 的特性。二者都在 server 里,C 错。章末资源
- Mastering Shiny(Hadley Wickham) — 入门主线,反应式编程与模块讲得最透,免费在线 入门
- Shiny 官网(Posit) — 参考文档、控件速查、示例图库与教程入口 参考
- teal(pharmaverse) — 临床探索应用框架,进阶;配 teal.modules.clinical 出临床模块 进阶
- DT 文档 — 交互表格控件参考,server 模式与回调 API 参考
- 本地精读:../clinical-viz-improved — 临床可视化改进示例,参考 参考
本章练习
- (易)给 7.2 的最小 app 再加一个
numericInput/sliderInput,按AGE下限过滤受试者:在filtered这个 reactive 里加一句d <- d[d$AGE >= input$minage, ],观察表格随滑块实时刷新。 - (中)把 7.2 的
tableOutput+renderTable换成DT::dataTableOutput+DT::renderDataTable(..., server = TRUE);再加一个downloadButton+downloadHandler,把当前过滤后的表导出成 CSV。 - (难)把 7.2 里"选治疗组 + 过滤"的逻辑抽成一个模块
adsl_filter_module(UI 端NS()、server 端moduleServer(),返回一个 reactive 的过滤后数据),在主 app 里像 7.4 的计数器那样调用它,体会封装与复用。
下一章:走出代码本身,走进社区与职业——去哪里提问、跟谁学、如何把 SAS 背景变成 R 世界的独特优势。