背景:一个诡异的 UI Bug
在一次 Chromium 定制开发中,我们遇到了一个极其诡异的 Bug:收藏按钮(StarView)在特定标签页切换操作后消失,但其占位空间仍然存在。按钮不可见,但标题栏的宽度已经为它预留了空间。
更奇怪的是,这个问题只在打开 5 个以上标签页时才能复现,4 个标签页完全正常。
排查这个 Bug 的过程,让我们深入理解了 Chromium Views 布局系统中一个非常微妙但极其重要的设计:LayoutManagerBase的两种GetPreferredSize重载。
LayoutManagerBase的两种GetPreferredSize
在ui/views/layout/layout_manager_base.cc中,有两个同名但行为截然不同的方法:
// 重载一:无约束版本 gfx::Size LayoutManagerBase::GetPreferredSize(const View* host) const { DCHECK_EQ(host_view_, host); if (!cached_preferred_size_) cached_preferred_size_ = CalculateProposedLayout(SizeBounds()).host_size; return *cached_preferred_size_; } // 重载二:有约束版本 gfx::Size LayoutManagerBase::GetPreferredSize( const View* host, const SizeBounds& available_size) const { DCHECK_EQ(host_view_, host); if (available_size.width().is_bounded()) { return CalculateProposedLayout(available_size).host_size; // 重新计算! } return GetPreferredSize(host); // 使用缓存 }关键差异:
- 无约束版:使用
cached_preferred_size_缓存,只在缓存失效时重新计算,且传入SizeBounds()(无约束) - 有约束版:当
available_size.width().is_bounded()时,每次都重新调用CalculateProposedLayout(available_size),将约束传入布局算法
SizeBounds的设计哲学
SizeBounds是 Chromium Views 中表示"可用空间约束"的类:
// 无约束:两个维度都是 nullopt SizeBounds() // 有约束:明确指定可用宽度和高度 SizeBounds(int width, int height)设计初衷:在 Chromium 的布局系统中,视图的 preferred size 有时依赖于可用空间(比如文本换行、弹性布局的 snap-to-zero)。SizeBounds提供了一种机制,让父视图在测量子视图时传递"我能给你多少空间"的信息。
这类似于 Android 的MeasureSpec,但更简洁:只有"有约束"和"无约束"两种状态(通过std::optional实现)。
约束如何向下传递
当LayoutPass1调用view->GetPreferredSize(SizeBounds(available, height))时,调用链如下:
View::GetPreferredSize(SizeBounds) → View::CalculatePreferredSize(SizeBounds) → GetLayoutManager()->GetPreferredSize(this, available_size) // 当 layout_manager_use_constrained_space_ = true(默认)时传递约束 → LayoutManagerBase::GetPreferredSize(host, available_size) → CalculateProposedLayout(available_size) // 触发完整布局计算注意view.cc:2622中的关键开关:
gfx::Size View::CalculatePreferredSize(const SizeBounds& available_size) const { if (HasLayoutManager()) { return GetLayoutManager()->GetPreferredSize( this, layout_manager_use_constrained_space_ ? available_size : SizeBounds()); // ↑ 默认 true,约束向下传递 } return gfx::Size(); }layout_manager_use_constrained_space_默认为true,意味着约束会一路向下传递到所有子视图的布局计算中。
实际布局(LayoutImpl)与测量(GetPreferredSize)的差异
这里有一个容易忽视的细节:实际布局时始终使用有约束的SizeBounds。
// layout_manager_base.cc void LayoutManagerBase::LayoutImpl() { // 使用视图的实际尺寸作为约束 auto proposed_layout = GetProposedLayout(host_view_->size()); // ... } ProposedLayout LayoutManagerBase::GetProposedLayout(const gfx::Size& host_size) const { return CalculateProposedLayout(SizeBounds(host_size)); // 始终有约束 }这意味着:
- 测量阶段(
GetPreferredSize):可以是有约束或无约束 - 布局阶段(
LayoutImpl):始终是有约束的(以视图实际尺寸为约束)
踩坑实践:两种路径的行为差异
在我们的 Bug 中,LocationBarLayout::LayoutPass1中有这样的代码:
void LocationBarLayout::LayoutPass1(int* entry_width, int reserved_width) { for (const auto& decoration : decorations_) { if (!decoration->auto_collapse && (decoration->max_fraction == 0.0)) { const auto available_size = some_feature_flag_enabled ? views::SizeBounds(*entry_width - reserved_width, decoration->height) // 有约束路径 : views::SizeBounds(); // 无约束路径 decoration->computed_width = decoration->view->GetPreferredSize(available_size).width(); *entry_width -= decoration->computed_width; } } }有约束路径:GetPreferredSize(SizeBounds(available, height))→ 触发 FlexLayout 的 snap-to-zero → 可能返回 0 →computed_width = 0→ 按钮宽度为 0 但SetVisible(true)仍被调用 →"占位空间在但按钮不可见"
无约束路径:GetPreferredSize(SizeBounds())→ 返回缓存的 preferred size → 正常宽度 → 按钮正常显示
设计启示
GetPreferredSize(SizeBounds)不是简单的查询:它可能触发完整的布局重计算,有性能开销- 约束传递是双向的:父视图的约束会影响子视图的 preferred size 计算结果
- 缓存失效时机:无约束版本使用缓存,有约束版本不使用缓存,这在高频调用场景下有显著性能差异
layout_manager_use_constrained_space_:这个开关提供了一个"防火墙",可以阻止约束向下传递,在某些场景下可以用来规避 snap-to-zero 问题