完成流程任务时,如果日志里出现下面的异常,第一反应很容易是监听器没有初始化,或者引擎里的服务对象为空:

ActivitiException: Exception while invoking TaskListener:
Exception while invoking TaskListener: null

但这里的 null 只是异常消息的一部分,不是空对象的名称。真正的定位信息在更深一层的 Caused by 中。

沿异常链找到业务代码

这次问题发生在 Activiti 7.1.0.M6、Spring 5.1.13、Java 8 的项目中。把重复的框架调用帧收起来后,异常层次是:

ActivitiException: Exception while invoking TaskListener
  caused by: ActivitiException: Exception while invoking TaskListener
  caused by: java.lang.NullPointerException
    at example.project.listener.BusinessListener.notify(BusinessListener.java:52)

包名是脱敏示例,关键是最内层指向了业务监听器的 notify,而不是给出了一个“Activiti 的 getVariable 实现必然有错”的结论。先在对应源码行设断点,再拆开这一行的表达式,通常比搜索最外层错误更有效。

服务和 ID 不为空,仍然可能空指针

当时关注的是读取审批变量:

Object value = runtimeService.getVariable(executionId, "pass");

即使 runtimeService 和 executionId 非空,也不代表变量存在、返回类型符合预期,或同一行后面的访问一定安全。例如下面的独立示例,会在返回 null 时因为自动拆箱而报空指针:

boolean pass = (Boolean) runtimeService.getVariable(executionId, "pass");

这说明一种可能的机制,不是对当年那一行缺失上下文的根因认定。更容易诊断的写法是先检查值和类型:

Object value = runtimeService.getVariable(executionId, "pass");
if (!(value instanceof Boolean)) {
    throw new IllegalStateException("审批变量 pass 缺失或类型不是 Boolean");
}
boolean pass = (Boolean) value;

这里选择缺失时拒绝继续执行,避免把“不知道是否通过”悄悄当成 false 或 true。实际默认值应由业务规则决定。

TaskService 和 RuntimeService 的参数不能混用

同一个变量名,可以从不同作用域读取:

调用第一个参数查找范围
runtimeService.getVariableexecutionId指定执行及可见的父作用域
taskService.getVariabletaskId任务及可见的执行作用域
taskService.getVariableLocaltaskId任务局部作用域

当时改用 TaskService.getVariable 后,故障现象消失了;这条经验值得记下,但不能因此推导“监听器里只能用 TaskService”。如果使用任务入口,应传任务 ID:

Object value = taskService.getVariable(delegateTask.getId(), "pass");

不能只把服务对象的名字替换掉,却继续传 executionId。不同入口得到不同结果时,要检查变量写在任务局部还是执行作用域,以及 create、assignment、complete 等事件触发时变量是否已设置。接口语义可对照 TaskService 与 RuntimeService 的对应版本说明。

推荐的定位顺序

先保存完整异常链,找到最内层业务行;再确认监听事件、任务 ID、执行 ID、变量的设置时机和作用域。将复合表达式拆成“读取—判空—检查类型—使用”几个步骤,逐项观察。

如果仍不能解释差异,用一个只有必要节点和监听器的测试流程比较两个读取入口。不要在尚未确定变量语义时,给审批判断随意补默认值;那可能只是让异常消失,却改变了审批结果。