Chromium Views BoxLayout 的前世今生

大家好,从今天开始我将在这个公众号更新我参与的开源项目的日记。目前我主要参与 Chromium 项目, 因此谈论更多的是Chromium,后续如果有参与其他项目,也会更新其他项目的内容。

背景

在查看这篇文章之前,推荐大家查看我的另一篇文章《我的Chromium Committer之路》

BoxLayout 是 Chromium 和 Chromium OS 中原生视图使用非常广泛的布局算法。今天我们从一个Bug入手,细说一下BoxLayout算法的前世今生。

布局从来都是Bug最多的地方,尤其是feature越多,越容易出问题。很不幸(如果幸运也就没有这篇文章了🐶),Chromium Views 的 BoxLayout 算法就有一个难以解决的问题:issues.chromium.org/issues/4129...

这个问题从17年提出,直到前不久作者才将其修复并合并到 Chromium 主线。

这个Bug是什么?这是BoxLayout在布局多行标签(包括其他行为类似的视图)时,无法将多行标签约束在合理宽度内。虽然垂直布局已经经过打补丁的方式处理,但是仍然无法解决水平布局时的问题。

为什么这个Bug出现长达6年都没人处理。

  1. 其一是BoxLayout牵扯太深,在整个Chromium代码库中,有大概600个文件,1000处使用(粗略估计)。这意味着一处小小的改动可能引发很严重的后果,而仅通过打补丁的方式已经无法解决这个问题。

  2. 其二,直到22年,Views 团队才提出了新的API用于解决多行标签以及类似的布局问题。然而,使用新的API意味着这个算法需要重写。因此,这堆代码就成了久久无人问津的屎山,Views团队也推荐业务方使用FlexLayout作为代替解决方案。

BoxLayout 的实现

好,说完了背景。我们说一下 BoxLayout拥有的功能,这样我们才能更加直接的理解代码逻辑:

  1. 支持水平/垂直布局,使用者可以在两者之间切换
  2. 支持宿主的padding、最小纵轴尺寸。支持子视图的 margin, spece between, margin 压缩。
  3. 支持水平和垂直对齐的能力。
  4. 支持子视图的Flex属性,这意味子视图可以拥有灵活的尺寸大小,如果需要算法应该自动为子视图分配额外的空间。

我们来看一个使用的例子:

c++ 复制代码
BoxLayout* layout = host_->SetLayoutManager(std::make_unique<BoxLayout>(
    /*布局方向*/BoxLayout::Orientation::kHorizontal,
    /*padding*/ gfx::Insets(), /*between spacing*/ 0,
    /*collapse_margins_spacing*/ true));

// 创建一个20X10的视图
View* v1 = new StaticSizedView(gfx::Size(20, 10));
// 视图拥有 5x5x5x5 的 margin
v1->SetProperty(kMarginsKey, gfx::Insets(5));
host_->AddChildView(v1);

View* v2 = new StaticSizedView(gfx::Size(20, 10));
// 视图拥有(6, 4,6, 4) 的margin
v2->SetProperty(kMarginsKey, gfx::Insets::VH(6, 4));
host_->AddChildView(v2);
EXPECT_EQ(gfx::Size(54, 22), layout->GetPreferredSize(host_.get()));

// 将宿主视图的大小设定成首选大小:即 54X22
host_->SizeToPreferredSize();
layout->Layout(host_.get());

// v1 的位置应该在 5,5 的坐标上,大小为 20x12
EXPECT_EQ(gfx::Rect(5, 5, 20, 12), v1->bounds());
EXPECT_EQ(gfx::Rect(30, 6, 20, 10), v2->bounds());

下面我们正式开始代码的逻辑解说,首先,最重要也是最核心的地方显然是新算法中的 CalculateProposedLayout方法.

最新的布局算法已经要求使用LayoutManagerBase作为基类,CalculateProposedLayout 是 LayoutManagerBase 所有计算的核心,它有几个要求:

  1. 幂等。这也是最重要的要求,就是因为之前的算法跟视图的状态有挂钩,导致了很多bug难以修复。
  2. 最好不能有 bug 回归,并且现有的功能要完全实现
  3. 修复上述 bug

这里三个都挺难的, 这几个需求都要使用新API才能实现,而这就意味着旧代码的大部分都不能使用。万辛我已经写过了FlexLayout的新算法,所以在BoxLayout中也使用了部分思想。

这是整个代码的整体,可以大致看一下有个印象就行,后面会有详细的拆解:

c++ 复制代码
ProposedLayout BoxLayout::CalculateProposedLayout(
    const SizeBounds& size_bounds) const {
  BoxLayoutData data;
  InitializeChildData(data);
​
  gfx::Insets insets = host_view()->GetInsets();
  data.interior_margin = Normalize(orientation_, inside_border_insets_);
​
  // TODO(crbug.com/1346889): In a vertical layout, if the width is not
  // specified, we need to first calculate the maximum width of the view, which
  // makes it convenient for us to call GetHeightForWidth later. If all views
  // are modified to GetPreferredSize(const SizeBounds&), we might consider
  // removing this part.
  SizeBounds new_bounds(size_bounds);
  if (!new_bounds.width().is_bounded() &&
      orientation_ == Orientation::kVertical) {
    new_bounds.set_width(CalculateMaxChildWidth(data));
  }
​
  NormalizedSizeBounds bounds = Normalize(orientation_, new_bounds);
​
  // When |collapse_margins_spacing_ = true|, the host_insets include the
  // leading of the first element and the trailing of the last one. It's crucial
  // to keep this in mind while reading here. Conversely, they are not included.
  if (collapse_margins_spacing_) {
    NormalizedInsets main_axis_insets =
        MaxAxisInsets(data.interior_margin,
                      data.child_data.empty() ? NormalizedInsets()
                                              : data.child_data.front().margins,
                      data.interior_margin,
                      data.child_data.empty() ? NormalizedInsets()
                                              : data.child_data.back().margins);
    data.host_insets = Normalize(
        orientation_, insets + Denormalize(orientation_, main_axis_insets));
  } else {
    data.host_insets = Normalize(orientation_, insets + inside_border_insets_);
  }
  bounds.Inset(data.host_insets);
​
  CalculatePreferredSize(Denormalize(orientation_, bounds), data);
​
  // Calculate the size of the view without boundary constraints. This is the
  // default size of our view.
  CalculatePreferredTotalSize(data);
​
  // Update the position information of the child views.
  UpdateFlexLayout(bounds, data);
​
  NormalizedSize host_size = data.total_size;
  host_size.Enlarge(data.host_insets.main_size(),
                    data.host_insets.cross_size());
​
  // TODO(weidongliu): see crbugs.com/1514004#c5, we handle compatibility here.
  // Maybe we can remove this in the future.
  if (collapse_margins_spacing_) {
    host_size.Enlarge(data.interior_margin.main_size(), 0);
  }
  data.layout.host_size = Denormalize(orientation_, host_size);
​
  CalculateChildBounds(size_bounds, data);
​
  return data.layout;
}

整个布局过程分为两个阶段。第一阶段,无边界条件下的自由布局。所有视图获得他们的最佳尺寸。第二阶段,为视图应用灵活尺寸分配已及对齐。大体上整个算法就是这两个流程。

第一阶段

c++ 复制代码
BoxLayoutData data;
InitializeChildData(data);

InitializeChildData(data); 这个方法主要是初始化所有的子视图在布局过程中需要的属性,O(n), 一次遍历,在遍历过程中剔除不被考虑的子视图,同时,还会提取参与布局的子视图的 margin和Flex值。

c++ 复制代码
gfx::Insets insets = host_view()->GetInsets();
data.interior_margin = Normalize(orientation_, inside_border_insets_);
​
// TODO(crbug.com/1346889): In a vertical layout, if the width is not
// specified, we need to first calculate the maximum width of the view, which
// makes it convenient for us to call GetHeightForWidth later. If all views
// are modified to GetPreferredSize(const SizeBounds&), we might consider
// removing this part.
SizeBounds new_bounds(size_bounds);
if (!new_bounds.width().is_bounded() &&
    orientation_ == Orientation::kVertical) {
  new_bounds.set_width(CalculateMaxChildWidth(data));
}
​
NormalizedSizeBounds bounds = Normalize(orientation_, new_bounds);
​
// When |collapse_margins_spacing_ = true|, the host_insets include the
// leading of the first element and the trailing of the last one. It's crucial
// to keep this in mind while reading here. Conversely, they are not included.
if (collapse_margins_spacing_) {
  NormalizedInsets main_axis_insets =
      MaxAxisInsets(data.interior_margin,
                    data.child_data.empty() ? NormalizedInsets()
                                            : data.child_data.front().margins,
                    data.interior_margin,
                    data.child_data.empty() ? NormalizedInsets()
                                            : data.child_data.back().margins);
  data.host_insets = Normalize(
      orientation_, insets + Denormalize(orientation_, main_axis_insets));
} else {
  data.host_insets = Normalize(orientation_, insets + inside_border_insets_);
}
bounds.Inset(data.host_insets);

这里我们处理宿主的margin和padding, 以及考虑是否需要压缩margin, 如果是压缩margin的场景。我们需要将宿主的padding和第一以及最后一个子视图margin作处理。

这里我们会注意到一段很突兀的代码:

c++ 复制代码
// TODO(crbug.com/1346889): In a vertical layout, if the width is not
// specified, we need to first calculate the maximum width of the view, which
// makes it convenient for us to call GetHeightForWidth later. If all views
// are modified to GetPreferredSize(const SizeBounds&), we might consider
// removing this part.
SizeBounds new_bounds(size_bounds);
if (!new_bounds.width().is_bounded() &&
    orientation_ == Orientation::kVertical) {
  new_bounds.set_width(CalculateMaxChildWidth(data));
}

这是为了处理兼容性问题,实际上在未来我会将这里删除。为什么需要这一段代码?

因为Views对新API的支持不完全(只有我和另一个google员工在处理,大部分工作是我在做)。在不完全支持新API的场景下,垂直布局的情况下如果我们不存在空间约束,我们需要首先计算出整个布局当中最大的子视图宽度,将它作为后续布局当中的宽度。

c++ 复制代码
  // from BoxLayout::CalculateProposedLayout
  CalculatePreferredSize(Denormalize(orientation_, bounds), data);

它的函数实现长这样:

c++ 复制代码
void BoxLayout::CalculatePreferredSize(const SizeBounds& bounds,
                                       BoxLayoutData& data) const {
  if (orientation_ == Orientation::kVertical) {
    for (size_t i = 0; i < data.num_children(); ++i) {
      BoxChildData& box_child = data.child_data[i];
      ChildLayout& child_layout = data.layout.child_layouts[i];
      SizeBound available_width = std::max<SizeBound>(
          0, bounds.width() - box_child.margins.cross_size());
​
      // Use the child area width for getting the height if the child is
      // supposed to stretch. Use its preferred size otherwise.
      int actual_width =
          cross_axis_alignment_ == CrossAxisAlignment::kStretch
              ? available_width.value()
              : std::min(
                    available_width.value(),
                    child_layout.child_view->GetPreferredSize({/* Unbounded */})
                        .width());
​
      if (collapse_margins_spacing_) {
        int height = child_layout.child_view->GetHeightForWidth(actual_width);
        box_child.preferred_size = NormalizedSize(height, actual_width);
      } else {
        actual_width = std::max(0, actual_width);
        int height = child_layout.child_view->GetHeightForWidth(actual_width);
        box_child.preferred_size = NormalizedSize(height, actual_width);
      }
    }
  } else {
    for (size_t i = 0; i < data.num_children(); ++i) {
      BoxChildData& box_child = data.child_data[i];
      ChildLayout& child_layout = data.layout.child_layouts[i];
​
      box_child.preferred_size = Normalize(
          orientation_, child_layout.child_view->GetPreferredSize(bounds));
    }
  }
}

这里,我们处理所有的子视图,计算所有子视图在没有空间约束下的尺寸。我们将它叫做首选尺寸。他的细节很多,很多都是feature上的处理。这和布局的整体思路关系不大,我们不介绍这么细节的东西。如果很感兴趣可以自己阅读这一段代码。

c++ 复制代码
// Calculate the size of the view without boundary constraints. This is the
// default size of our view.
CalculatePreferredTotalSize(data);

对应的实现:

c++ 复制代码
void BoxLayout::CalculatePreferredTotalSize(BoxLayoutData& data) const {
  for (size_t i = 0; i < data.num_children(); ++i) {
    BoxChildData& box_child = data.child_data[i];
​
    int main_size = box_child.preferred_size.main();
    if (!collapse_margins_spacing_) {
      main_size += box_child.margins.main_size();
    }
​
    if (main_size == 0 && box_child.flex == 0) {
      continue;
    }
​
    NormalizedInsets child_margins = GetChildMargins(data, i);
​
    if (i < data.num_children() - 1) {
      if (collapse_margins_spacing_) {
        main_size +=
            std::max(between_child_spacing_, child_margins.main_trailing());
      } else {
        main_size += between_child_spacing_;
      }
    }
​
    int cross_size = box_child.preferred_size.cross();
    if (cross_axis_alignment_ == CrossAxisAlignment::kStart) {
      cross_size +=
          data.max_cross_margin.leading() + child_margins.cross_trailing();
    } else if (cross_axis_alignment_ == CrossAxisAlignment::kEnd) {
      cross_size +=
          data.max_cross_margin.trailing() + child_margins.cross_leading();
    } else {
      // We implement center alignment by moving the central axis.
      int view_center = box_child.preferred_size.cross() / 2;
      int old_cross_center_pos = data.cross_center_pos;
      data.cross_center_pos = std::max(
          data.cross_center_pos, child_margins.cross_leading() + view_center);
      cross_size = data.cross_center_pos + box_child.preferred_size.cross() -
                   view_center + child_margins.cross_trailing();
      // If the new center point has moved to the right relative to the original
      // center point, then we need to move all the views to the right, so the
      // original total size increases by |data.cross_center_pos -
      // old_cross_center_pos|.
      data.total_size.Enlarge(
          0, std::max(0, data.cross_center_pos - old_cross_center_pos));
    }
    data.total_size.SetSize(data.total_size.main() + main_size,
                            std::max(data.total_size.cross(), cross_size));
  }
​
  EnsureCrossSize(data);
}

嗯。看注释。这里比较有意思的是就是纵轴的居中对齐逻辑。这里引入了中心点的概念。在对齐过程中,实际上是计算中心点的位置,我们只需要中心点的位置是最宽视图的中心就行了。其他视图在布局时根据这个点去偏移自己的位置。

到这里,第一阶段就完成了,在没有空间约束时。这就是最后的布局结果(除了水平对齐之外)。

第二阶段

c++ 复制代码
  // from BoxLayout::CalculateProposedLayout

  // Update the position information of the child views.
  UpdateFlexLayout(bounds, data);
​
  NormalizedSize host_size = data.total_size;
  host_size.Enlarge(data.host_insets.main_size(),
                    data.host_insets.cross_size());
​
  // TODO(weidongliu): see crbugs.com/1514004#c5, we handle compatibility here.
  // Maybe we can remove this in the future.
  if (collapse_margins_spacing_) {
    host_size.Enlarge(data.interior_margin.main_size(), 0);
  }
  data.layout.host_size = Denormalize(orientation_, host_size);
​
  CalculateChildBounds(size_bounds, data);

UpdateFlexLayout 函数的实现

首先我们来看一下UpdateFlexLayout的实现, 最开始是对各种状态的初始化:

c++ 复制代码
void BoxLayout::UpdateFlexLayout(const NormalizedSizeBounds& bounds,
                                 BoxLayoutData& data) const {
  if (bounds.main() == 0 && bounds.cross() == 0) {
    return;
  }
​
  int total_main_axis_size = data.total_size.main();
  int flex_sum = std::accumulate(
      data.child_data.cbegin(), data.child_data.cend(), 0,
      [](int total, const BoxChildData& data) { return total + data.flex; });
​
  // Free space can be negative indicating that the views want to overflow.
  SizeBound main_free_space = bounds.main() - total_main_axis_size;
  int total_padding = 0;
  int current_flex = 0;
  const size_t num_child = data.num_children();
  const int preferred_cross = data.total_size.cross();
  data.total_size = NormalizedSize();
  data.cross_center_pos = 0;

接下来的代码就包括纵轴对齐的新结果和具有灵活尺寸属性的视图以及类多行标签的视图调整自己大小的核心关键点。这里没必要都看,我会节选核心部分来讲述:

c++ 复制代码
  for (size_t i = 0; i < num_child; ++i) {
    BoxChildData& box_child = data.child_data[i];
    ChildLayout& child_layout = data.layout.child_layouts[i];
​
    NormalizedInsets child_margins = GetChildMargins(data, i);
​
    if (!collapse_margins_spacing_) {
      data.total_size.Enlarge(box_child.margins.main_leading(), 0);
    }
​
    box_child.actual_bounds.set_origin_main(data.total_size.main());
    SizeBound cross_axis_size =
        bounds.cross().is_bounded() && bounds.cross().value() > 0
            ? bounds.cross()
            : preferred_cross;
    if (cross_axis_alignment_ == CrossAxisAlignment::kStretch ||
        cross_axis_alignment_ == CrossAxisAlignment::kCenter) {
      cross_axis_size -= child_margins.cross_size();
    }
​
    // Calculate flex padding.
    int current_padding = 0;
    int child_flex = box_child.flex;
    if (main_free_space.is_bounded() && child_flex > 0) {
      current_flex += child_flex;
      int quot = (main_free_space.value() * current_flex) / flex_sum;
      int rem = (main_free_space.value() * current_flex) % flex_sum;
      current_padding = quot - total_padding;
      // Use the current remainder to round to the nearest pixel.
      if (std::abs(rem) * 2 >= flex_sum) {
        current_padding += main_free_space > 0 ? 1 : -1;
      }
      total_padding += current_padding;
    }
​
    // Set main axis size.
    box_child.preferred_size = Normalize(
        orientation_,
        GetPreferredSizeForView(
            child_layout.child_view,
            NormalizedSizeBounds(
                std::max<SizeBound>(0, bounds.main() - data.total_size.main()),
                cross_axis_size)));
    int child_main_axis_size = box_child.preferred_size.main();
​
    int child_min_size = GetMinimumSizeForView(child_layout.child_view);
    if (child_min_size > 0 && !collapse_margins_spacing_) {
      child_min_size += box_child.margins.main_leading();
    }
​
    box_child.actual_bounds.set_size_main(
        std::max(child_min_size, child_main_axis_size + current_padding));
    if (box_child.actual_bounds.size_main() > 0 || box_child.flex > 0) {
      data.total_size.set_main(box_child.actual_bounds.max_main());
      if (i < num_child - 1) {
        if (collapse_margins_spacing_) {
          data.total_size.Enlarge(
              std::max(between_child_spacing_, child_margins.main_trailing()),
              0);
        } else {
          data.total_size.Enlarge(between_child_spacing_, 0);
        }
      }
​
      if (!collapse_margins_spacing_) {
        data.total_size.Enlarge(child_margins.main_trailing(), 0);
      }
    } else if (!collapse_margins_spacing_) {
      // TODO(weidongliu): see crbugs.com/1514004#c4. If a view with a 0
      // preferred size has a margin, it will be considered for main_leading but
      // not for main_trailing.
      data.total_size.set_main(data.total_size.main() +
                               child_margins.main_leading());
    }
​
    int cross_size = box_child.preferred_size.cross();
    if (cross_axis_alignment_ == CrossAxisAlignment::kStart) {
      cross_size +=
          data.max_cross_margin.leading() + child_margins.cross_trailing();
    } else if (cross_axis_alignment_ == CrossAxisAlignment::kEnd) {
      cross_size +=
          data.max_cross_margin.trailing() + child_margins.cross_leading();
    } else {
      int view_center = box_child.preferred_size.cross() / 2;
      // When center aligning, if the size is an odd number, we want the view to
      // be to the left instead of to the right.
      if (cross_axis_alignment_ == CrossAxisAlignment::kCenter) {
        view_center += box_child.preferred_size.cross() & 1;
      }
​
      int old_cross_center_pos = data.cross_center_pos;
      data.cross_center_pos = std::max(
          data.cross_center_pos, child_margins.cross_leading() + view_center);
      cross_size = data.cross_center_pos + box_child.preferred_size.cross() -
                   view_center + child_margins.cross_trailing();
​
      // If the new center point has moved to the right relative to the original
      // center point, then we need to move all the views to the right, so the
      // original total size increases by |data.cross_center_pos -
      // old_cross_center_pos|.
      data.total_size.Enlarge(
          0, std::max(0, data.cross_center_pos - old_cross_center_pos));
    }
    data.total_size.set_cross(std::max(data.total_size.cross(), cross_size));
  }

我们先来看这里:

c++ 复制代码
// Calculate flex padding.
int current_padding = 0;
int child_flex = box_child.flex;
if (main_free_space.is_bounded() && child_flex > 0) {
  current_flex += child_flex;
  int quot = (main_free_space.value() * current_flex) / flex_sum;
  int rem = (main_free_space.value() * current_flex) % flex_sum;
  current_padding = quot - total_padding;
  // Use the current remainder to round to the nearest pixel.
  if (std::abs(rem) * 2 >= flex_sum) {
    current_padding += main_free_space > 0 ? 1 : -1;
  }
  total_padding += current_padding;
}

计算视图的flex属性。将溢出或者缺少的宽度平均分配给各个子视图(当然最终的结果肯定不是平分的,看下面的分析)。

c++ 复制代码
// Set main axis size.
box_child.preferred_size = Normalize(
    orientation_,
    GetPreferredSizeForView(
        child_layout.child_view,
        NormalizedSizeBounds(
            std::max<SizeBound>(0, bounds.main() - data.total_size.main()),
            cross_axis_size)));
int child_main_axis_size = box_child.preferred_size.main();

计算在给定的尺寸约束下,视图实际占据的空间大小。这将是视图实际占据的大小。好了,在这里,视图的灵活尺寸已经处理完毕了。


我们回到CalculateProposedLayout:

c++ 复制代码
  NormalizedSize host_size = data.total_size;
  host_size.Enlarge(data.host_insets.main_size(),
                    data.host_insets.cross_size());
​
  // TODO(weidongliu): see crbugs.com/1514004#c5, we handle compatibility here.
  // Maybe we can remove this in the future.
  if (collapse_margins_spacing_) {
    host_size.Enlarge(data.interior_margin.main_size(), 0);
  }
  data.layout.host_size = Denormalize(orientation_, host_size);
​
  CalculateChildBounds(size_bounds, data);

我们来看CalculateChildBounds,它的主要作用是主轴对齐,核心代码是这段:

c++ 复制代码
ChildLayout& child_layout = data.layout.child_layouts[i];
BoxChildData& box_child = data.child_data[i];
NormalizedRect actual = box_child.actual_bounds;
actual.Offset(start.main(), start.cross());
// If the view exceeds the space, truncate the view.
if (actual.origin_main() < data.host_insets.main_leading()) {
  actual.SetByBounds(data.host_insets.main_leading(), actual.origin_cross(),
                     actual.max_main(), actual.max_cross());
}
​
if (actual.max_main() > data.host_insets.main_leading() + available_main) {
  actual.SetByBounds(actual.origin_main(), actual.origin_cross(),
                     data.host_insets.main_leading() + available_main,
                     actual.max_cross());
}
child_layout.bounds = Denormalize(orientation_, actual);

这里为我们展示了,如果子视图在应用对齐后溢出了宿主视图的空间。那么子视图就会被阶段。

总结

相比于FlexLayout. 其实垂直和水平布局的差异才是这个算法最难实现的根源,毕竟一个多行文本在水平和垂直布局当中对布局的影响可以说是天差地别。另一个难点就是新API的支持不完全,我们必须要对旧的API做兼容。

好了,就先写这么多。

感谢各位读者的观看。


这篇文章也同步更新在我的公众号中

相关推荐
却尘4 天前
Server Actions 深度剖析(2):缓存管理与重新验证,如何用一行代码干掉整个客户端状态层
前端·客户端·next.js
程序员老刘6 天前
Google突然“变脸“,2026年要给全球开发者上“紧箍咒“?
android·flutter·客户端
Lei活在当下7 天前
【业务场景架构实战】1. 多模块 Hilt 使用原则和环境搭建
性能优化·架构·客户端
万少12 天前
可可图片编辑 HarmonyOS(3)应用间分享图片
前端·harmonyos·客户端
程序员老刘16 天前
Dart MCP翻车了!3.9.0版本无法运行,这个坑你踩过吗?
flutter·ai编程·客户端
程序员老刘17 天前
Cursor vs Claude Code vs AS+AI助手:谁才是客户端的编程神器?
flutter·ai编程·客户端
麦客奥德彪23 天前
React native 项目函数式编程的背后-另类的架构InversifyJS 依赖注入(DI)
react native·架构·客户端
fouryears_234171 个月前
Flutter InheritedWidget 详解:从生命周期到数据流动的完整解析
开发语言·flutter·客户端·dart
程序员老刘1 个月前
Flutter 3.35 更新要点解析
flutter·ai编程·客户端
红橙Darren1 个月前
手写操作系统 - 编译链接与运行
android·ios·客户端