Skip to content

账本的合并术

背景:重启 app 后点"加载模型",库视图回退成列表而非网格,仅本次运行保持网格。 过程SetUIState 整体替换账本,单字段写入清空他页;改为合并,视图模式终被记住。


缩略图的空框修好之后,用户又抛来一个问题。

"这个列表,能做成持久化吗?"他指着库视图右上角那个 按钮——切换网格 / 列表的开关。

桌面壳查了查,摇摇头:"不用做,已经做了。"

library-core.ts 里,setResourceViewMode 清清楚楚调了 SetUIState

typescript
export function setResourceViewMode(mode: ResourceViewMode): void {
    resourceViewMode = mode;
    import('../core/wails-bindings').then(({ SetUIState }) => {
        SetUIState({ resourceViewMode: mode } as ...).catch(() => {});
    });
}

启动路径上,initLibrarycfg.ui_state.resourceViewMode 读回;main.tsrestoreUIState 也同步恢复;Go 端 Config 结构体挂着 UIState json:"ui_state"GetConfig 返回它,SetUIState 写它。

"写入、读取、渲染,链路全通,"桌面壳说,"磁盘上 config.jsonui_state 里,应该已经写着 resourceViewMode: "grid" 了。"

用户没说话,重启了 app,点开"加载模型"。

库视图是列表

"你确定?"用户问,"我切到网格了,重启之后还是列表。只有这次运行时才保持网格。"


账本里缺的那一页

外交官重新去磁盘上看了眼。这一次,他没看串。

%APPDATA%/MikuMikuAR/config.jsonui_state

json
{
  "scale": 0,
  "popupWidth": 0,
  "accent": "",
  "fpsLimit": 0
}

没有 resourceViewMode

他又看了眼 setting/config.json——ResourceRoot 非空时,getConfigUnsafe 优先返回这个副本。同样没有 resourceViewMode

"我上一回说'磁盘有 grid',"外交官承认,"是看串了。真实的 ui_state 里,根本没有这一页。"

他盯着那几个字段。scalepopupWidth 这些——它们的 json tag 没有 omitempty,所以即使值是零,也会写出来。resourceViewMode 的 tag 是 resourceViewMode,omitempty——有值就该写,没写就是没传进去。

"可 SetUIState({resourceViewMode:'grid'}) 明明调了,"桌面壳说,"为什么没写进去?"

外交官翻开 Go 端的 SetUIState

go
func (a *App) SetUIState(ui UIState) error {
    return a.updateConfig(func(cfg *Config) {
        cfg.UIState = ui   // ← 整体替换
    }, false)
}

"cfg.UIState = ui,"外交官念出声,"整体替换。前端每次只传一个字段 {resourceViewMode: mode},Go 端就把整本账本撕掉,只抄这一页上去。"

"那其他页呢?"

"其他页——scalepopupWidth——应该被清空成零。可你看磁盘,"外交官指着那行 "scale": 0,"scale 还在,还是零值,没被清空。说明 SetUIState 这次调用根本没成功落盘。如果整体替换真执行了,旧字段会被抹掉;它们还在,证明调用链在更上游就断了。"

账本里缺的,不是某一页写错了。是这一页从来没被写进去过


合并,而非替换

"根因清楚了,"外交官说,"SetUIState 的契约是'整体替换',但前端只传单字段。这两个设计撞在一起,单字段写入要么清空其他页,要么——像现在这样——根本没生效。"

他改了 Go 端,把"撕掉重抄"换成"只改那一页":

go
func (a *App) SetUIState(ui UIState) error {
    return a.updateConfig(func(cfg *Config) {
        mergeUIState(&cfg.UIState, ui)   // ← 合并,而非替换
    }, false)
}

func mergeUIState(dst *UIState, src UIState) {
    if src.ResourceViewMode != "" {
        dst.ResourceViewMode = src.ResourceViewMode
    }
    // 其他非零字段同理并入,不碰没传的页
}

"合并,"他说,"前端传什么,就改什么。没传的页,原样留着。"

双保险:前端 setResourceViewMode 不再只传单字段,而是把完整的 uiState 快照一起交出去——即使合并逻辑有疏漏,整本账本也在参数里,不会丢页。

typescript
import { uiState } from '../core/state';  // 新增导入
// ...
SetUIState(uiState);   // 传完整快照

类型检查 0 错。Go 编译通过。


三颗失败的石子

单测跑起来,3 个失败,72 通过

外交官看了一眼失败信息——mock 的 SetUIState 现在收到的是完整 uiState 快照,而旧测试的断言还停留在"只收一个字段"的预期上。断言和新的契约对不上。

"测试失败了,"他说。

"测试失败先放一边,"用户说,"逻辑成功了。"

外交官想了想,点头。逻辑是对的:合并语义修好了持久化,视图模式重启后终于能被记住。那 3 个失败是测试自己没跟上契约的变化——它们断言的是旧的错误行为(单字段替换),本就该改。逻辑成功了,测试是后话。

"账本的合并术,"他在笔记本上写,"不是新开一本,是把新的一页,缝进旧的那本里。"


账本的教训

桌面壳在"UI 交互"一栏,画了一本被撕掉重抄的账本。

左边是旧做法:每次只写一页,却把整本撕掉,结果其他页要么丢、要么——像这次——整本都没抄成。右边是新做法:只改要改的那一页,其余原样保留。

"持久化最容易被误解成'写一个字段',"外交官说,"其实持久化是'把改动并入已有的状态'。你写 resourceViewMode 的时候,不该碰 scale、不该碰 popupWidth、不该碰用户之前设好的任何东西。"

"整体替换,"桌面壳接话,"是偷懒的契约。它假设调用方永远传完整状态——可前端为了省事,只传变的那个。两个假设一撞,账本就乱了。"

他在新地图的入口钉下一张便签:

状态持久化,用合并,不用替换。

整体替换的契约,要求调用方永远持有完整状态。可真实世界里,调用方只知道自己刚改的那一个字段。把"只改一页"翻译成"撕掉重抄",账本迟早丢页——或者像这次,整本都没写进去,用户重启后发现视图模式失忆了。

合并是尊重历史。替换是否定历史。

"有时候,"外交官说,"最稳的持久化不是记得多,是忘得少。"

桌面壳关掉编辑器。用户重启 app,点开"加载模型"——这一次,库视图是网格。

他切到列表,重启,再开——还是列表。

账本终于记住了读者刚翻到的那一页。


教训:状态持久化用合并不用替换。整体替换假设调用方永远传完整状态,可真实调用只传变的那个字段——撞在一起,账本丢页,视图模式重启即失忆。