Workspace Model 深水区¶
Workspace Model 是 IntelliJ Platform 2024.2 起推荐给第三方插件使用的新项目结构 API。它用统一的 entity storage 表示模块、库、SDK、Facet、内容根、源码根等项目结构元素,并与旧 Project Model 保持互操作。
Project Model、Workspace Model 与 External System 已覆盖总体定位。本章继续展开 entity 声明、读取、修改、事件监听、索引和迁移策略。
核心判断¶
| 场景 | 建议 |
|---|---|
| 只想知道某文件属于哪个模块 | ProjectFileIndex |
| 读取模块、库、内容根、源码根快照 | WorkspaceModel.currentSnapshot |
| 批量修改项目结构 | WorkspaceModel.update() + MutableEntityStorage |
| 导入外部系统模型 | 用 replaceBySource() 替换同一来源实体 |
| 长期缓存 Module/Library 相关信息 | 用 EntityPointer 或 symbolic id,不要直接缓存 mutable Project Model 对象 |
| 插件自定义 project data | 先评估现有 entities、external mappings 和持久化 service;第三方自定义 entity 生成目前不可用 |
不要为了一个简单查询引入复杂修改流程;也不要在需要批量项目结构变更时继续拼多个旧 ModifiableModel。
三层存储模型¶
| 类型 | 作用 |
|---|---|
VersionedEntityStorage |
当前版本化存储入口 |
ImmutableEntityStorage |
当前项目结构的不可变快照 |
MutableEntityStorage |
用于构建或修改 entity graph 的可变副本 |
典型读:
val storage: ImmutableEntityStorage =
WorkspaceModel.getInstance(project).currentSnapshot
典型写:
WorkspaceModel.getInstance(project).update("Update project structure") { builder ->
// mutate builder
}
快照不可变。你拿到 currentSnapshot 后,即使项目结构之后变化,这个快照也不会被原地修改。这让遍历和索引构建更安全,但也意味着长时间持有快照可能看到旧数据。
Entity 与 EntitySource¶
Workspace Model 中每个项目结构元素都是 WorkspaceEntity:
interface ExampleEntity : WorkspaceEntity {
val name: String
val root: VirtualFileUrl
}
官方目前说明:自定义 entity 的生成能力尚未对第三方插件开放,生成实现只在 IntelliJ IDEA 项目自身启用。因此第三方插件通常应:
- 使用平台已提供的 entities,例如
ModuleEntity、ContentRootEntity、SourceRootEntity、LibraryEntity。 - 用 external mappings 关联插件自己的外部数据。
- 对插件私有配置继续用
PersistentStateComponent。 - 仅在官方开放稳定生成链路后,再把自定义 project structure 数据建成 Workspace Entity。
每个 entity 都有 entitySource,表示实体从哪里来:
.idea/.iml配置文件。- 外部系统导入结果,例如 Gradle/Maven。
- 自动生成的模型。
- 其他可序列化来源标记。
entitySource 对 replaceBySource() 非常关键。外部系统同步时,通常只替换自己来源的那部分实体,不碰用户手工创建或其他系统创建的实体。
Entity 属性类型¶
Entity 属性类型不是任意 Kotlin 类型。官方支持的类型包括:
- primitive,例如
Int、Boolean。 String。enum。- 不可变
data class,其中属性也必须是支持类型。 sealedclass,且实现为不可变 data class 或 object。- 可空变体。
List、Set、Map,元素也必须是支持类型。VirtualFileUrl。EntitySource。SymbolicEntityId。- 对其他 entity 的引用。
属性分三类:
| 类型 | 规则 |
|---|---|
| Mandatory | 没有默认值,创建 entity 时必须传入 |
| Optional | 有 @Default getter 或 nullable 默认 null |
| Computable | 有 getter 但没有 @Default,不保存到 storage,每次访问计算 |
不要把 Project、Module、VirtualFile、service、可变集合或平台对象塞进 entity 属性。它们不可序列化,也会破坏 Workspace Model 的跨进程和快照语义。
VirtualFileUrl¶
Workspace Model entity 中的文件或目录引用应使用 VirtualFileUrl,不要用普通 String 保存路径。
创建:
val workspaceModel = WorkspaceModel.getInstance(project)
val url = workspaceModel
.getVirtualFileUrlManager()
.getOrCreateFromUrl("file:///path/to/project/src")
查询引用该 URL 的 entities:
val storage = workspaceModel.currentSnapshot
val entities = storage.getVirtualFileUrlIndex()
.findEntitiesByUrl(url)
原因:
- 内存占用更低。
- 能高效定位
VirtualFile。 - 可通过索引反查引用。
- 部分 entity 类型支持移动/重命名后的自动更新。
SymbolicEntityId¶
SymbolicEntityId 是实体的业务键,通常来自用户可见名称或配置文件,而不是随机生成 ID。
例子:
val moduleId = ModuleId("core")
val moduleEntity = workspaceModel.currentSnapshot.resolve(moduleId)
规则:
- 同一 storage 中不能存在两个相同
SymbolicEntityId的实体。 - 如果 entity 的 symbolic id 改变,引用旧 ID 的属性会自动更新到新 ID。
- symbolic reference 是软引用,目标实体可能不存在。
- 可以用
EntityStorage.referrers()快速查找引用某个 symbolic id 的实体。
适合用 symbolic id 的场景:
- 模块依赖另一个模块。
- Facet 依附某个模块。
- 库依赖、SDK 引用等按名称解析的结构。
如果关系必须强一致,应使用 parent-child hard link。
Parent-Child 关系¶
Workspace Model entity graph 是有向无环图。parent-child 关系用于表达强所有权。
例子:
val contentRoot = ContentRootEntity(url, emptyList(), entitySource) {
this.module = moduleEntity
}
关系维护规则:
- 删除 parent 会级联删除 non-null parent 的 child。
- 删除 child 会更新 parent 中的引用。
- 从 parent 的 children 列表中移除 child,如果 child 的 parent 是 non-null,child 会被删除。
- 如果 child 的 parent 可空,则可能变成 detached child。
硬关系适合模块包含内容根、内容根包含源码根等结构。不要把松散关联强行建成 parent-child,否则删除一个实体会连带删除不该删除的数据。
读取 Entity¶
获取所有某类实体:
val storage = WorkspaceModel.getInstance(project).currentSnapshot
val modules = storage.entities<ModuleEntity>().toList()
按 ID 查询:
val module = storage.resolve(ModuleId("core"))
按来源查询:
storage.entitiesBySource { it is GradleEntitySource }
.forEach { entity ->
// imported by Gradle
}
按路径查询:
val moduleEntities = storage.getVirtualFileUrlIndex()
.findEntitiesByUrl(sourceRootUrl)
.mapNotNull {
when (it) {
is SourceRootEntity -> it.contentRoot.module
is ContentRootEntity -> it.module
else -> null
}
}
读操作建议:
- 先拿一个 snapshot,再在 snapshot 内完成遍历。
- 不要在遍历过程中反复读取
currentSnapshot。 - 如果读取 PSI/VFS/Project Model 混合数据,仍遵守 read action 规则。
- 长期缓存时保存
EntityPointer或SymbolicEntityId,不要保存旧 snapshot 里的 entity 实例。
添加 Entity¶
添加 module entity 示例:
val workspaceModel = WorkspaceModel.getInstance(project)
val virtualFileUrlManager = workspaceModel.getVirtualFileUrlManager()
val baseDir = virtualFileUrlManager.getOrCreateFromUrl("file:///workspace/core")
val entitySource = LegacyBridgeJpsEntitySourceFactory
.getInstance(project)
.createEntitySourceForModule(baseDir, null)
WorkspaceModel.getInstance(project).update("Add module") { builder ->
val module = ModuleEntity(
name = "core",
dependencies = emptyList(),
entitySource = entitySource,
)
builder.addEntity(module)
}
官方 usage 示例中提到,LegacyBridgeJpsEntitySourceFactory 是 internal API,但在插件中被例外允许使用。使用时要把版本验证纳入测试矩阵。
添加内容根和源码根:
workspaceModel.update("Add source root") { builder ->
val contentRoot = ContentRootEntity(contentRootUrl, emptyList(), module.entitySource) {
sourceRoots = listOf(
SourceRootEntity(
url = sourceRootUrl,
rootType = SourceRootTypeId("java-source"),
entitySource = module.entitySource,
)
)
}
builder.modifyModuleEntity(module) {
contentRoots = contentRoots + contentRoot
}
}
添加整棵 entity tree 时,只要把 root entity 加进 storage,child entities 会自动加入。
修改和删除 Entity¶
修改 entity 要先在 MutableEntityStorage 中解析目标:
WorkspaceModel.getInstance(project).update("Rename module") { builder ->
val module = ModuleId("old-name").resolve(builder) ?: return@update
builder.modifyModuleEntity(module) {
name = "new-name"
}
}
删除:
workspaceModel.update("Remove module") { builder ->
val module = ModuleId("obsolete").resolve(builder) ?: return@update
builder.removeEntity(module)
}
修改注意:
MutableEntityStorage不是线程安全对象。- builder 内不要启动后台任务或跨线程使用。
- 写操作应在平台允许的写上下文中执行。
- 批量修改尽量一次 update,减少事件风暴。
- 如果从 snapshot 拿到 entity,再进入 update,要确认 entity 仍能在 builder 中解析。
applyChangesFrom()¶
applyChangesFrom() 把一个 mutable storage 中记录的新增、修改、删除应用到另一个 mutable storage。
适合:
- 并行解析多个
.iml/ 外部配置文件,每个文件生成一个局部 storage,最后合并。 - Project Structure Dialog 中先累积用户修改,点击 Apply 后一次提交。
- 复杂导入流程先离线构建结果,再合并到主 builder。
伪代码:
val imported = MutableEntityStorage.create()
loadExternalModel(imported)
workspaceModel.update("Apply imported model") { builder ->
builder.applyChangesFrom(imported)
}
如果只是修改少量已知实体,直接在 update builder 中修改即可,不需要额外 storage。
replaceBySource()¶
replaceBySource() 是外部系统同步的关键能力:按 entitySource 谓词替换 storage 的一部分,并尽量最小化实体变化。
场景:Gradle/Maven 配置文件变化后,重新解析外部模型,只替换来自该外部系统的 entities,不影响用户手工配置和其他系统实体。
val imported = MutableEntityStorage.create()
loadGradleModel(imported)
workspaceModel.update("Sync Gradle model") { builder ->
builder.replaceBySource(
{ source -> source is GradleEntitySource },
imported,
)
}
使用原则:
entitySource设计必须稳定、可序列化、可过滤。- 谓词范围越精确越好。
- 不要用
replaceBySource()替换所有实体。 - 导入前先在临时 storage 中构建完整结果。
- 失败时不要提交半成品 storage。
Event Listening¶
Workspace Model 事件通过 WorkspaceModel.eventLog 暴露为 Kotlin Flow。
事件顺序固定:
EntityChange.RemovedEntityChange.ReplacedEntityChange.Added
订阅示例:
@Service(Service.Level.PROJECT)
class ExampleWorkspaceIndex(
private val project: Project,
private val cs: CoroutineScope,
) {
init {
cs.launch(CoroutineName("Example workspace model listener")) {
val workspaceModel = WorkspaceModel.getInstance(project)
workspaceModel.eventLog.collectIndexed { index, event ->
if (index == 0) {
rebuild(event.storageAfter)
} else {
update(event)
}
}
}
}
}
注意:
- 第一个事件携带初始 storage,适合构建初始索引。
- 如果在 project opened 后才订阅,可能错过最早几次更新,这是预期行为。
- 事件是异步的,不能假设基于
WorkspaceFileIndex或旧 Project Model 的索引已经同步更新。 - 对缓存做增量更新时,要保存最后已处理的 storage 版本或用 event 中的 before/after 信息。
EntityPointer 与长期缓存¶
不要把 Module、Library 或 snapshot 中的 entity 实例长期作为 map key。它们有可变性、生命周期和内存泄漏问题。
使用 EntityPointer:
val pointer = moduleEntity.createPointer()
val currentEntity = pointer.resolve(
WorkspaceModel.getInstance(project).currentSnapshot,
)
限制:
- pointer 不包含原 storage,不会直接造成 storage 泄漏。
- storage 修改后,pointer 通常能解析到同一实体。
- 如果 entity 被
replaceBySource()删除或 ID 复用,pointer 可能解析为null或不同实体。 - 对严肃缓存,需要配合
eventLog处理删除和替换。
External Mapping¶
如果需要把外部数据和 Workspace Entity 关联,可以用 external mapping:
data class ExampleData(val value: String)
val key = ExternalMappingKey.create<ExampleData>("com.example.workspace.data")
workspaceModel.update("Attach data") { builder ->
val mapping = builder.getMutableExternalMapping(key)
val module = ModuleId("core").resolve(builder) ?: return@update
mapping.addMapping(module, ExampleData("metadata"))
}
读取:
val storage = workspaceModel.currentSnapshot
val mapping = storage.getExternalMapping(key)
val data = mapping.getDataByEntity(moduleEntity)
External mapping 适合辅助索引或导入过程中的关联数据,不适合替代插件配置持久化。
线程、写动作与性能¶
Workspace Model 写操作改变项目结构,必须遵守平台写入规则。推荐模式:
- 后台读取外部配置或计算变更。
- 构建临时
MutableEntityStorage或 DTO。 - 回到合适写上下文。
- 一次
WorkspaceModel.update()提交。
不要:
- 在
update()block 里做 IO、网络、外部进程。 - 持有 builder 跨线程。
- 每个文件变更都提交一次 update。
- 在 listener 中同步触发复杂二次修改造成循环。
大型导入要配合进度和取消。Workspace Model 负责项目结构一致性,不负责替代后台任务管理。
与 Project Model 互操作¶
新 Project Model 实现已经把数据存进 Workspace Model 对应实体中。常见映射:
| Project Model | Workspace Model |
|---|---|
Module |
ModuleEntity |
ContentEntry |
ContentRootEntity |
SourceFolder |
SourceRootEntity |
Sdk |
SdkEntity |
Library |
LibraryEntity |
Facet |
FacetEntity |
迁移建议:
- 读文件归属:继续用
ProjectFileIndex,它更直接。 - 读结构快照:用
currentSnapshot。 - 批量修改结构:用 Workspace Model。
- 调已有产品 API:按对方 API 要求传
Module/Library,必要时桥接。 - 兼容旧 IDE:保留 Project Model 分支或降低功能。
QA 清单¶
- 目标平台低于 2024.2 时有兼容策略。
- 不访问 Workspace Model 内部
impl,除官方 usage 明确例外允许的 API 外。 - 读取时使用单个 immutable snapshot。
- 写入时使用一次
WorkspaceModel.update()批量提交。 entitySource设计稳定且可序列化。- 外部系统同步使用
replaceBySource()精确替换。 - 长期缓存使用
EntityPointer或 symbolic id,并订阅事件修正。 - 不把
Project、Module、VirtualFile等平台对象放进 entity 属性。 - eventLog listener 使用 service scope,项目关闭时自动取消。
- 大项目导入有进度、取消和测试覆盖。