在 Flowable 中实现任意节点跳转,首选官方 RuntimeService.createChangeActivityStateBuilder(),而不是修改运行时表,也通常不需要自己编写底层 Command。对普通用户任务,取得当前任务的 executionId 后调用 moveExecutionToActivityId(executionId, targetActivityId).changeState(),Flowable 会在同一命令事务中取消源节点运行时数据、调整 Execution 树、重建目标作用域,并通过 Agenda 启动目标节点。
但"任意"不等于"随便"。单个任务跳转、多个并行令牌合并、一个令牌拆成多分支、多实例实例退出、嵌入式子流程跨层和 Call Activity 跨流程,必须选择不同 API。目标节点是否合法、谁有权跳转、是否允许重复执行服务任务,也仍然是平台层的责任。
本文以 Flowable 8.0.0 为源码基线。Flowable 8 已切换到 Spring Framework 7、Spring Boot 4 和 Jackson 3,升级旧项目时应先核对运行环境。
一、核心结论与问题边界
先给结论:Flowable 已经内置状态迁移 API
一个普通节点跳转可以压缩成下面几行:
java
Task task = taskService.createTaskQuery()
.taskId(taskId)
.singleResult();
runtimeService.createChangeActivityStateBuilder()
.moveExecutionToActivityId(task.getExecutionId(), targetActivityId)
.changeState();
这段代码表达的是:只移动当前任务对应的那个 Execution 到目标活动。如果当前节点处在会签或并行分支中,这种 execution 级 API 通常比 activityId 级 API 更精确。

图 1:Flowable 把节点跳转实现成公开 Builder API,底层仍然进入 Command、DynamicStateManager 与 Agenda。
最新源码里,调用链到底经过了什么
Flowable 8.0.0 的公开入口是 ChangeActivityStateBuilder。源码注释直接说明,它是"改变流程实例状态"的辅助构建器,通过 RuntimeService.createChangeActivityStateBuilder() 获得。
| 源码类 | 主要职责 | 跳转中的作用 |
|---|---|---|
RuntimeService |
暴露流程实例运行时 API | 创建状态迁移 Builder |
ChangeActivityStateBuilderImpl |
收集 execution、activity、变量和目标映射 | 描述"从哪里移动到哪里" |
RuntimeServiceImpl |
把 Builder 交给 CommandExecutor | 进入引擎事务 |
ChangeActivityStateCmd |
校验参数并取得 DynamicStateManager | 状态迁移命令入口 |
DefaultDynamicStateManager |
解析移动容器和活动结构 | 把业务请求转换成 Execution 集合 |
AbstractDynamicStateManager |
删除旧状态、重建作用域和新执行 | 完成真正的执行树迁移 |
Agenda |
安排 Continue Process 操作 | 执行目标 ActivityBehavior |
源码调用顺序如下:
text
RuntimeService.createChangeActivityStateBuilder()
-> ChangeActivityStateBuilderImpl.changeState()
-> RuntimeServiceImpl.changeActivityState(...)
-> CommandExecutor.execute(new ChangeActivityStateCmd(...))
-> DynamicStateManager.moveExecutionState(...)
-> AbstractDynamicStateManager.doMoveExecutionState(...)
-> Agenda.planContinueProcessOperation(newExecution)
ChangeActivityStateCmd 有两项关键校验:没有配置任何 move/enable 操作会直接报错;使用 activityId 级迁移或启用事件子流程时必须提供 processInstanceId。executionId 级迁移可以从 execution 自身解析流程实例,因此不强制设置流程实例 ID。
二、关键概念与能力差异
六类核心 API 应该怎么选
Flowable 的状态迁移 API 本质上处理三种令牌关系:一对一、多对一和一对多;每种关系又可以按 executionId 或 activityId 指定来源。

图 2:先判断要移动几个运行时令牌,再选择 executionId 还是 activityId。
| 迁移意图 | 推荐 API | 典型场景 |
|---|---|---|
| 单个令牌 → 单个节点 | moveExecutionToActivityId |
普通驳回、只移动某个会签参与人 |
| 当前活动 → 单个节点 | moveActivityIdTo |
单实例流程的节点前进或后退 |
| 多个令牌 → 单个节点 | moveExecutionsToSingleActivityId |
收拢指定并行分支 |
| 多个活动 → 单个节点 | moveActivityIdsToSingleActivityId |
按活动批量收拢整个 Fork 区域 |
| 单个令牌 → 多个节点 | moveSingleExecutionToActivityIds |
从一个执行令牌人工拆出多个分支 |
| 当前活动 → 多个节点 | moveSingleActivityIdToActivityIds |
按活动批量创建多个目标分支 |
还有三类跨作用域 API:moveActivityIdToParentActivityId 用于从被调用流程返回父流程;moveActivityIdToSubProcessInstanceActivityId 用于从父流程进入新的被调用流程实例;enableEventSubProcessStartEvent 用于启用事件子流程启动事件。
普通用户任务如何安全跳到指定节点
应用层不应直接让前端提交一个 activityId 后立即执行。推荐把权限、目标计算、幂等和业务审计封装在服务中,再调用 Flowable API。
java
@Transactional
public JumpResult jumpUserTask(JumpRequest request) {
Task task = taskService.createTaskQuery()
.taskId(request.taskId())
.active()
.singleResult();
if (task == null) {
throw new IllegalStateException("任务不存在或已经处理");
}
jumpPolicy.checkOperator(request.operatorId(), task);
jumpPolicy.checkTarget(task, request.targetActivityId());
idempotencyService.reserve(request.idempotencyKey());
runtimeService.createChangeActivityStateBuilder()
.moveExecutionToActivityId(
task.getExecutionId(), request.targetActivityId())
.processVariable("lastJumpOperator", request.operatorId())
.processVariable("lastJumpReason", request.reason())
.changeState();
operationLog.save(ProcessOperation.jump(request, task));
return new JumpResult(task.getProcessInstanceId(),
task.getTaskDefinitionKey(), request.targetActivityId());
}
示例把操作者和原因放入流程变量只是为了说明 Builder 支持同步设置变量。生产系统中,完整操作日志仍应保存到业务审计表:流程变量会被覆盖,也不适合承担不可篡改的多次操作轨迹。
三、模型架构与运行机制
为什么 executionId 通常比 activityId 更安全
moveActivityIdTo(currentActivityId, targetActivityId) 看起来更简单,但它的来源是"流程实例内当前位于这个 activityId 的活动执行"。在多实例和并发结构中,一个 activityId 可能对应多个 execution。
Flowable 8 的 resolveMoveExecutionEntityContainers 会查询流程实例的子执行,按 activityId 筛选,再识别多实例根、父 execution 和多实例作用域。官方多实例测试也证明:在多实例活动中按 activityId 移动,可能迁移该活动下的全部相关实例;按 executionId 则可以只移动指定实例。
因此建议:
- 由某条具体待办触发的驳回、跳转,优先使用
task.getExecutionId()。 - 管理员要整体收拢某个活动时,才使用 activityId 级 API。
- 并行或会签页面必须展示"移动当前分支"还是"移动全部分支",不能共用一个模糊按钮。
- 请求到达服务端后重新查询 Task 和 Execution,不能相信前端缓存的运行时 ID。
源码如何删除旧状态并创建新状态
DefaultDynamicStateManager.moveExecutionState 先把 Builder 中的请求解析为 MoveExecutionEntityContainer,再构造 ProcessInstanceChangeState 并调用 doMoveExecutionState。真正迁移时,源码大致执行以下步骤:
- 查找流程实例和当前活动的 Execution,设置本次迁移携带的流程变量。
- 解析目标 FlowElement、父子流程层级、多实例结构和 Call Activity 信息。
- 删除当前 execution 的子执行、任务、作业和事件订阅,并写入"Change activity to ..."删除原因。
- 如果离开子流程,向上删除不再需要的父作用域;如果进入子流程,重新创建嵌入式作用域层级。
- 为每个目标 FlowElement 创建新的 child execution,并设置局部变量。
- 重建目标节点的边界事件;异步任务创建 Job,普通节点交给 Agenda 继续执行。
- 处理待启用的事件子流程启动事件。

图 3:Flowable 不是修改旧 Task 的节点编号,而是取消旧状态并按目标 BPMN 重新进入。
这个过程解释了三个现象:跳转后的 taskId 通常是新的;目标用户任务的分配逻辑和任务监听器会重新执行;目标节点带有定时器、消息或信号边界事件时,相应运行时数据会重新创建。
四、核心场景与处理策略
前进跳转、后退跳转和跳过节点有区别吗
从引擎 API 看,moveExecutionToActivityId 并不要求目标一定在历史路径上,也不要求目标是前驱节点。因此前进、后退和跨节点在调用形式上相同。
区别来自业务语义:
- 后退到已办节点:通常需要重新生成待办,并保留上一轮历史,不能修改旧历史记录。
- 前进跳过审批:必须记录跳过原因,并检查被跳过节点是否承担必需控制。
- 跳到服务任务:可能再次调用支付、发货或外部接口,必须有业务幂等。
- 跳到结束事件:可能直接结束流程,需要同步业务单据状态。
- 跳到网关:网关依赖令牌数量、入口路径和条件,不能只看目标 ID。
平台层应维护节点操作白名单,例如 canJumpFrom、canJumpTo、允许角色、是否允许跨作用域、是否允许触发外部副作用。Flowable 负责执行迁移,不负责替企业决定迁移是否合理。
并行网关应该怎样移动令牌
并行网关最常见错误,是只把一个分支跳到 Join,却期待整个并行区域立即结束。Join 等待的是满足同步条件的令牌,不是某个任务的完成标志。

图 4:并行场景的核心不是节点名称,而是迁移前后应该保留几个 Execution。
java
// 一个令牌拆为两个分支
runtimeService.createChangeActivityStateBuilder()
.moveSingleExecutionToActivityIds(
sourceExecutionId, List.of("legalTask", "financeTask"))
.changeState();
// 两个活动的令牌收拢到并行区域之后
runtimeService.createChangeActivityStateBuilder()
.processInstanceId(processInstanceId)
.moveActivityIdsToSingleActivityId(
List.of("legalTask", "financeTask"), "archiveTask")
.changeState();
Flowable 官方 ChangeStateForGatewaysTest 同时覆盖了 activityId 和 executionId 两套一对多、多对一迁移。测试还演示了把多个分支移动到并行 Join,只有令牌数量和作用域正确时,网关才能继续流转。
五、数据、规则与状态设计
多实例会签怎样处理
多实例活动至少包含 MI Root、实例 execution、任务和计数变量。产品首先要区分三个动作:
| 产品动作 | 运行时意图 | 建议实现 |
|---|---|---|
| 当前审批人退回 | 只移动当前实例 | 使用当前 Task 的 executionId |
| 整组会签退回 | 退出全部实例并回到前置节点 | 收集相关 execution 或按 activityId 整体迁移 |
| 会签中加人/减人 | 增减实例,不改变主路径 | 使用多实例增减 API,而不是节点跳转 |
Flowable 8 的解析代码会识别 isMultiInstanceRoot()、多实例父活动和同一父 execution 下的实例,并为不同父作用域建立独立移动容器。官方 ChangeStateForMultiInstanceTest 覆盖了顺序/并行多实例、嵌套多实例子流程和网关嵌套场景。
这并不意味着会签可以不测试。迁移后仍应校验 nrOfInstances、nrOfActiveInstances、nrOfCompletedInstances、completionCondition、局部变量和业务投票结果。引擎保证运行时结构迁移,企业要保证"本轮会签是否作废、重开还是保留已有票数"的业务语义。
子流程和 Call Activity 如何跨层跳转
嵌入式 SubProcess 与 Call Activity 不是一回事。嵌入式子流程仍在同一个流程实例和定义中,Flowable 的普通 activity/execution 迁移可以在进入或退出时重建对应作用域。
Call Activity 会启动独立的被调用流程实例。Flowable 为此提供专门 API:
java
// 从被调用流程退出,回到父流程节点
runtimeService.createChangeActivityStateBuilder()
.processInstanceId(childProcessInstanceId)
.moveActivityIdToParentActivityId("childTask", "parentReview")
.changeState();
// 从父流程节点进入新的被调用流程实例
runtimeService.createChangeActivityStateBuilder()
.processInstanceId(parentProcessInstanceId)
.moveActivityIdToSubProcessInstanceActivityId(
"parentReview", "childTask", "callActivity")
.changeState();
第二个 API 还有指定子流程定义版本的重载。Flowable 官方 Call Activity 测试验证了父子流程间迁移、calledElement 表达式、变量解析和指定定义版本。跨流程跳转必须使用这些 API,不能把子流程 activityId 当作父流程普通节点直接移动。
六、工程实现与系统集成
历史、监听器和操作日志会发生什么
状态迁移使用"取消源活动并启动目标活动"的语义,而不是把源任务正常 complete。Flowable 网关测试会断言 ACTIVITY_CANCELLED 事件;动态状态管理器删除旧执行时也传入 Change activity to ... 原因。
企业项目需要特别检查:
- 源任务的 delete/取消监听器是否会执行,是否错误触发正常完成逻辑。
- 源活动历史是否闭合,目标活动是否生成新一轮历史。
- 目标节点的 create/assignment 监听器是否具有幂等性。
- 异步服务任务是否生成新 Job,失败重试会不会重复外部副作用。
- 流程变量和局部变量是否要继承、覆盖或重新计算。
审批意见、操作人、源节点、目标节点、理由、流程定义版本和幂等键应保存到独立业务审计表。历史表适合还原引擎轨迹,但不能替代企业操作日志。
并发、权限和事务怎样设计
节点跳转与正常完成可能同时发生。应用服务应在调用前检查活动任务,在引擎事务内再次依赖实体版本做乐观锁控制;遇到 FlowableOptimisticLockingException 时返回"任务状态已变化",不要自动重复跳转。
建议至少增加以下控制:
- 服务器根据当前模型、历史路径和角色重新计算允许目标。
- 接口使用幂等键,阻止重复点击和网关重放。
- 前端只展示候选目标,不拥有最终授权。
- 流程库与业务库同源时使用本地事务;异库时使用 Outbox 或可靠事件。
- 跳到 Service Task、Send Task、结束事件前执行高风险节点检查。
- 每次迁移记录快照,包括活动 executionId 集合和迁移后的任务集合。
七、安全、性能与治理要求
低代码平台如何封装成可配置能力
在云程低代码开发平台这类产品中,不应把 ChangeActivityStateBuilder 直接暴露给页面。设计器可以为节点配置"允许退回范围、是否可跳过、是否允许跨子流程、操作角色";运行时策略层把用户动作转换为 executionId/activityId 集合;Flowable 适配层选择一对一、多对一或一对多 API;审计层统一记录结果。 
一个实用的候选目标策略是:默认只允许历史经过的人工节点;管理员纠偏可使用显式白名单;多实例、并行和 Call Activity 只开放专用操作。这样既利用 Flowable 强大的状态迁移能力,又不会把底层技术能力变成不受控的"万能跳转"。
八、案例实践与迁移路线
必须覆盖哪些集成测试
| 用例 | 必查结果 |
|---|---|
| 普通 B → A 后退 | 旧任务取消,A 新任务创建,历史产生新一轮 |
| 普通 A → C 前进 | B 未创建,跳过理由被记录 |
| 带边界定时器的节点 | 源 Timer 消失,目标 Timer 按模型创建 |
| 单个并行分支移动 | 其他分支保持活动,Join 等待关系正确 |
| 多分支收拢 | 所选 execution 被取消,只留下目标令牌 |
| 单个会签实例退回 | 其他实例和 MI 计数符合业务策略 |
| 整组会签退回 | MI Root、任务和变量无残留 |
| 进入/退出嵌入式子流程 | 父作用域、局部变量和边界事件正确 |
| Call Activity 跨流程 | 父子实例关系、版本和历史正确 |
| 完成与跳转并发 | 只有一方提交,另一方得到状态冲突 |
| 目标异步服务任务 | Job、重试和业务幂等正确 |
不要只断言待办数量。还要检查 Execution 树、任务、作业、定时器、事件订阅、变量、历史活动、历史任务和业务单据状态。
1. 常见错误
- 直接修改
ACT_RU_EXECUTION.ACT_ID_或复制旧版 Activiti 的自定义跳转代码。 - 在会签或并行场景中默认使用
moveActivityIdTo,误把全部实例一起迁移。 - 只移动一个分支到 Join,却期待网关自动越过其他分支。
- 把旧 Task 的 taskDefinitionKey 改成目标节点,继续复用 taskId。
- 允许前端提交未校验的任意目标 ID。
- 跳到服务任务前没有检查外部副作用和幂等。
- 把流程历史表当作业务操作日志。
- 捕获引擎异常后仍提交业务状态。
- 只测试普通节点,没有测试定时器、事件、子流程和并发。
九、平台落地、测试与选型
标准答案:Flowable 任意节点跳转怎么做
**普通任务跳转:**查询当前 Task,取得 executionId,调用 runtimeService.createChangeActivityStateBuilder().moveExecutionToActivityId(executionId, targetActivityId).changeState()。
**并行或多实例:**先明确要移动一个令牌、多个令牌还是拆出多个令牌,再分别选择 moveExecutionToActivityId、moveExecutionsToSingleActivityId 或 moveSingleExecutionToActivityIds;需要按活动批量操作时使用对应 activityId 版本。
**跨父子流程:**嵌入式子流程可由动态状态管理器重建作用域;Call Activity 应使用 moveActivityIdToParentActivityId 或 moveActivityIdToSubProcessInstanceActivityId。
Flowable 8.0.0 底层会通过 ChangeActivityStateCmd 和 DynamicStateManager 取消旧状态、重构 Execution 树、重建边界事件,并由 Agenda 启动目标节点。企业仍需在 API 外增加权限、目标白名单、幂等、业务审计和完整集成测试。