LocalHub 要列出一批本地文件。第一版是按扩展名贴通用图标,做完一看就知道不行:用户认图标是靠形状和颜色,不是靠「这是个 exe」。资源管理器里那个 Edge 图标、那个文件夹的浅黄色,才是他要找的线索。
所以得拿系统真实图标。Windows 上这件事的正规入口是 IShellItemImageFactory。
一、为什么不用 SHGetFileInfo
老办法是 SHGetFileInfo,能拿到 HICON。但它返回的尺寸固定,要么 16,要么 32,放到现在的列表里模糊得没法看。想要大图标就得自己处理缩放,而缩放 HICON 很容易把边缘糊掉。
IShellItemImageFactory::GetImage 的好处是可以直接指定期望尺寸,由 Shell 那边按最佳质量返回位图——如果是矢量资源或包含多尺寸的图像列表,它能给你一张干净的大图。
let factory: IShellItemImageFactory = item.cast()?;
let size = SIZE { cx: 64, cy: 64 };
let bitmap = unsafe { factory.GetImage(size, SIIGBF_ICONONLY)? };
二、位图不是最终结果
拿到的 HBITMAP 是一块 GDI 位图,得先转成 RGBA 像素数组才能用。转换过程要处理一个细节:位图默认是上下翻转的(bottom-up),行序和常规图像相反,取像素时要从最后一行开始读,否则出来的图标是倒的。
转完之后你会发现另一个问题:图标的可见内容并不铺满整张图。
三、透明边距才是真正的麻烦
Shell 返回的位图是按一个固定画布给的,不同程序注册的图标在里面占的大小不一样。有的图标画满,有的只占中间一半,四周全是透明像素。如果直接把整张图塞进 img 里显示,结果是:同一个列表里,有的图标很大,有的缩成一小团,视觉上参差不齐。文件夹图标尤其明显——浅黄色的文件夹常常只占画布的三分之二。
解决办法是扫一遍 alpha 通道,求出「有效像素」的包围盒,再把这块裁出来。
const ALPHA_MIN: u8 = 128;
fn visible_bounds(px: &[u8], w: usize, h: usize) -> Option<(usize, usize, usize, usize)> {
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
for y in 0..h {
for x in 0..w {
if px[(y * w + x) * 4 + 3] >= ALPHA_MIN {
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
}
if x1 < x0 || y1 < y0 { None } else { Some((x0, y0, x1, y1)) }
}
这段逻辑本身很简单,难的是那个阈值定在哪。
四、阈值定错的两种翻车方式
设太低(比如 0,只排除完全透明):图标边缘会带一圈几乎看不见的抗锯齿像素,包围盒被撑到接近整个画布,等于没裁。表现出来就是「裁了但看起来没裁」。
设太高(比如 240):这一版我踩过。当时想的是「只保留实心像素」,结果很多图标的边缘是渐变的,主体最外一两像素的 alpha 只有 200 出头,全部被判成透明。包围盒收缩到图标内部一小块,裁出来的图只剩中间一团,四周被切掉。更糟的是纯线条类图标(比如白色描边的 PDF 图标),几乎全部 alpha 都在 200 以下,visible_bounds 直接返回 None。
而我当时的兜底写错了——None 时返回了一张全白的占位图。于是那天的表现是:资源管理器里明明有图标的文件,在 LocalHub 里全变成白方块。排查了一圈以为是 IPC 传数据出错,最后发现是阈值 240 + 兜底给了白色。
现在固定在 128。这个值的理由是:alpha 半透明确实可以视为「视觉上存在」,而低于 128 的像素多半是抗锯齿和阴影,不该参与定界。兜底也改成了「None 就返回原图不裁」,宁可偏大也不要出白块。
另外文件夹的图标要单独处理。系统返回的文件夹图标自带一点阴影,如果按 128 裁,阴影边缘会被切掉一小条,看起来像缺角。我的做法是对文件夹类别放宽到 64,并且固定配色——浅黄底加描线,这样整个列表里的文件夹是统一的。
五、缓存
一次列目录几百个文件,每个都调一次 Shell 接口,首次渲染能卡出明显停顿。所以必须缓存。
缓存键用「完整路径 + 修改时间」,而不是只用路径。原因是用户可能替换同名文件,或者给快捷方式换了目标,这时候图标是应该变的。加上修改时间之后,又不会每次启动都重新提取。
let key = format!("{}|{}", path.display(), mtime);
if let Some(cached) = cache.get(&key) {
return Ok(cached.clone());
}
缓存放在内存里,进程退出就没了。也考虑过落盘,但图标本身很小、提取也不慢,落盘反而要处理版本失效和清理,不值。
结论
整套流程不算复杂,但坑集中在两个地方:位图的行序,以及 alpha 阈值的取值。前者是纯技术细节,查文档能解决;后者没有标准答案,只能对着自己的图标集合试,试到列表在视觉上齐整为止。
经验是:阈值宁可偏低,兜底宁可返回原图。裁得不够好看是审美问题,裁没了是功能事故。
上一篇:不用打包工具的前端项目该怎么组织 · 下一篇:没有了