Appearance
账本的合并术
背景:重启 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(() => {});
});
}启动路径上,initLibrary 从 cfg.ui_state.resourceViewMode 读回;main.ts 的 restoreUIState 也同步恢复;Go 端 Config 结构体挂着 UIState json:"ui_state",GetConfig 返回它,SetUIState 写它。
"写入、读取、渲染,链路全通,"桌面壳说,"磁盘上 config.json 的 ui_state 里,应该已经写着 resourceViewMode: "grid" 了。"
用户没说话,重启了 app,点开"加载模型"。
库视图是列表。
"你确定?"用户问,"我切到网格了,重启之后还是列表。只有这次运行时才保持网格。"
账本里缺的那一页
外交官重新去磁盘上看了眼。这一次,他没看串。
%APPDATA%/MikuMikuAR/config.json 的 ui_state:
json
{
"scale": 0,
"popupWidth": 0,
"accent": "",
"fpsLimit": 0
}没有 resourceViewMode。
他又看了眼 setting/config.json——ResourceRoot 非空时,getConfigUnsafe 优先返回这个副本。同样没有 resourceViewMode。
"我上一回说'磁盘有 grid',"外交官承认,"是看串了。真实的 ui_state 里,根本没有这一页。"
他盯着那几个字段。scale、popupWidth 这些——它们的 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 端就把整本账本撕掉,只抄这一页上去。"
"那其他页呢?"
"其他页——scale、popupWidth——应该被清空成零。可你看磁盘,"外交官指着那行 "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,点开"加载模型"——这一次,库视图是网格。
他切到列表,重启,再开——还是列表。
账本终于记住了读者刚翻到的那一页。
教训:状态持久化用合并不用替换。整体替换假设调用方永远传完整状态,可真实调用只传变的那个字段——撞在一起,账本丢页,视图模式重启即失忆。