R 临床实战From SAS to R, In Depth
全书目录 / 第 7 章
07

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 详谈)。
一句话锚点:SAS 程序是"函数被调用一次";Shiny 应用是"一堆函数被一张图反复、按需地调用"。你要管理的不再是执行顺序,而是"谁依赖谁"。
SAS 误区:把 server 函数当成一段从头到尾顺序执行的脚本。server 函数在应用启动时只运行一次,它内部的 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
逐行解读:
  1. ui 的 class 是 shiny.tag.list——说明 fluidPage() 把控件正确组装成了一棵 HTML 标签树;selectInput 的第一个参数 "arm" 就是稍后 input$arm 的名字。
  2. server 是一个普通 function,签名固定为 function(input, output, session);Shiny 启动时对每个用户会话调用它一次。
  3. app 的 class 是 shiny.appobj——这是 shinyApp() 把 ui 和 server 焊接后的可运行对象,构造成功即代表依赖图无装配错误。
  4. choices 那行证明数据真的读进来了、ARM 有 4 个水平外加"全部",下拉框不会是空的。
SAS 误区:在 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
逐行解读:
  1. doubled 随 input$x 实时变化——它是活的:输入框一改,任何读 doubled() 的地方立刻拿到新值。
  2. snapshot 初值 NULL,只有点"记录"按钮才被 observeEvent 写入当前的 doubled 值——它是被冻结的历史值,不随输入框实时变。
  3. 界面上因此同时显示两个数:live_doubled(实时)与 snapshot(点按钮那一刻的快照)。这个对比能让你彻底体会 reactive 的"惰性实时"与 observeEvent 的"事件触发"之别。
  4. 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
逐行解读:
  1. ns <- NS(id) 生成一个"加前缀"的函数:ns("plus") 实际渲染成 a-plus(见输出末行)。这就是命名空间——两个实例的按钮 id 分别是 a-plus 和 b-plus,绝不撞车。
  2. moduleServer(id, function(input, output, session){...}):模块内部照常写 input$plus,Shiny 自动帮你补上命名空间前缀,你无需在模块里手动拼 id。
  3. UI 端和 Server 端必须用同一个 id(这里都是 "a" / "b"),否则命名空间对不上,控件点了没反应——这是模块最常见的 bug。
  4. counterServer 末尾 return(reactive({ n() })) 让父级能读到子模块的状态,实现模块间通信。
SAS 误区:把模块当成 SAS 宏那样"文本展开、共享全局变量"。模块之间是隔离的响应式作用域,实例 a 的 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
逐行解读:
  1. DT::datatable() 的 class 是 datatables htmlwidget,说明它构造出了一个交互表控件;pageLength = 5 控制每页行数。
  2. ggsurvplot() 返回的是一个复合列表(含曲线、风险表、p 值),ggplotly() 只吃其中的 ggplot,所以必须写 p$plot;直接丢 p 会报错。
  3. ggplotly() 的 class 是 plotly htmlwidget——静态图成功升级为交互图,构造验证通过。
  4. 这里用 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
逐行解读:
  1. teal_data(dm = dm) 的 class 是 teal_data——它不只是个 list,还会记录"这些数据是由哪段代码产生的",为可追溯与复现服务。
  2. names(td) 得到 dm,这就是模块里 datanames = "dm" 引用的键名,二者必须对得上。
  3. 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 托管的免费/付费云)。四步走:

  1. 装 rsconnect:install.packages("rsconnect") ——这是负责打包上传的客户端包。
  2. 授权账号:在 shinyapps.io 网站后台复制一段 token,粘进 rsconnect::setAccountInfo(...),把本机与该账号绑定(一次性)。
  3. 部署:rsconnect::deployApp("app_dir") ——它扫描依赖、打包、上传、在云端重建环境。
  4. 拿 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
企业级方案 Posit Connect:受监管环境里通常不会用公网的 shinyapps.io,而是自建 Posit Connect 服务器——它在企业内网托管 Shiny/Quarto,支持访问权限控制、环境锁(renv)、集中日志与审计,是 GxP 场景下更现实的部署载体。部署 API 与 rsconnect 基本一致。
SAS 误区:以为云端会"自动装好我本地的所有包"。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 版本与宏库
验证的落点仍是"包 + 测试 + 锁环境"(见第 0/5 章)。把 app 的核心逻辑抽成纯函数 + 模块(第 6 章的包开发思路),就能对这些纯函数写 testthat 单元测试——响应式外壳难测,纯计算内核好测,这是让 Shiny 可验证的关键架构决策。
SAS 误区 / 定位澄清:别把 Shiny app 当成递交产物(submission deliverable)的默认载体。监管机构要的是可复现的静态 listing、数据集与分析报告(第 4 章的 rtables/Quarto 输出)。Shiny/teal 的合理定位是探索与审阅工具——帮医学、统计、DM 团队交互式地看数据、查一致性、做敏感性分析。真正进 eCTD 的,仍应是从同一套锁定代码生成的静态产物;交互 app 是它们旁边的"驾驶舱",不是递交物本身。

测验:reactive() 与 observeEvent() 的核心区别是什么?

选 B。reactive() 是惰性纯计算,像函数一样被下游 () 取值并缓存;observeEvent() 不返回值给下游,而是在触发条件满足时急切地"做一件事"(写状态、弹提示、写日志)。D 恰好说反了——缓存是 reactive 的特性。二者都在 server 里,C 错。

章末资源

本章练习

  1. (易)给 7.2 的最小 app 再加一个 numericInput / sliderInput,按 AGE 下限过滤受试者:在 filtered 这个 reactive 里加一句 d <- d[d$AGE >= input$minage, ],观察表格随滑块实时刷新。
  2. (中)把 7.2 的 tableOutput + renderTable 换成 DT::dataTableOutput + DT::renderDataTable(..., server = TRUE);再加一个 downloadButton + downloadHandler,把当前过滤后的表导出成 CSV。
  3. (难)把 7.2 里"选治疗组 + 过滤"的逻辑抽成一个模块 adsl_filter_module(UI 端 NS()、server 端 moduleServer(),返回一个 reactive 的过滤后数据),在主 app 里像 7.4 的计数器那样调用它,体会封装与复用。

下一章:走出代码本身,走进社区与职业——去哪里提问、跟谁学、如何把 SAS 背景变成 R 世界的独特优势。