Tìm hiểu sâu về RenderingNG: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Lễ hội Koji Ishi
Koji Ishi

Tôi là Ian Kilpatrick, kỹ sư trưởng nhóm bố cục Blink cùng với Koji Ishii. Trước khi làm việc trong nhóm Blink, tôi từng là kỹ sư front-end (trước khi Google đảm nhận vai trò "kỹ sư front-end"), các tính năng xây dựng trong Google Tài liệu, Drive và Gmail. Sau khoảng 5 năm đảm nhiệm vai trò đó, tôi đã đặt ra một thách thức lớn khi chuyển sang nhóm Blink, học C++ một cách hiệu quả trong công việc và cố gắng tăng tốc cơ sở mã Blink cực kỳ phức tạp. Đến hôm nay, tôi mới chỉ hiểu được một phần tương đối nhỏ. Cảm ơn bạn đã dành thời gian trong khoảng thời gian này. Tôi thấy yên tâm khi thấy rất nhiều "kỹ sư giao diện người dùng đã khôi phục" đã trở thành "kỹ sư trình duyệt" trước tôi.

Kinh nghiệm trước đây đã hướng dẫn riêng tôi khi còn ở trong nhóm Blink. Là một kỹ sư front-end, tôi liên tục gặp phải tình trạng trình duyệt không nhất quán, vấn đề về hiệu suất, lỗi hiển thị và thiếu tính năng. LayoutNG là cơ hội để tôi giúp khắc phục những vấn đề này trong hệ thống bố cục của Blink một cách có hệ thống, và thể hiện tổng công sức của nhiều kỹ sư trong những năm qua.

Trong bài đăng này, tôi sẽ giải thích một thay đổi lớn về cấu trúc như thế này có thể làm giảm và giảm thiểu các loại lỗi cũng như vấn đề về hiệu suất như thế nào.

Quang cảnh 30.000 feet của kiến trúc công cụ bố trí

Trước đây, cây bố cục của Blink là "cây có thể biến đổi".

Cho thấy cây như được mô tả trong nội dung sau.

Mỗi đối tượng trong cây bố cục chứa thông tin đầu vào, chẳng hạn như kích thước có sẵn do thành phần mẹ áp dụng, vị trí của bất kỳ độ chính xác đơn nào và thông tin đầu ra, ví dụ: chiều rộng và chiều cao cuối cùng của đối tượng hoặc vị trí x và y của đối tượng.

Các đối tượng này được giữ lại giữa các lần kết xuất. Khi có sự thay đổi về kiểu, chúng tôi đã đánh dấu đối tượng đó là bẩn và tương tự như tất cả đối tượng mẹ của đối tượng đó trong cây. Khi chạy giai đoạn bố cục của quy trình kết xuất, chúng ta sẽ dọn dẹp cây, di chuyển mọi đối tượng bẩn, sau đó chạy bố cục để chúng chuyển sang trạng thái sạch.

Chúng tôi nhận thấy kiến trúc này dẫn đến nhiều loại vấn đề mà chúng tôi sẽ mô tả dưới đây. Nhưng trước tiên, hãy quay lại và xem xét dữ liệu đầu vào và đầu ra của bố cục.

Về mặt lý thuyết, việc chạy bố cục trên một nút trong cây này sẽ lấy "Kiểu cộng với DOM" và mọi quy tắc ràng buộc của thành phần mẹ trong hệ thống bố cục mẹ (lưới, khối hoặc linh hoạt), sẽ chạy thuật toán ràng buộc bố cục và tạo ra kết quả.

Mô hình khái niệm được mô tả ở trên.

Kiến trúc mới của chúng tôi chính thức hoá mô hình khái niệm này. Chúng ta vẫn có cây bố cục, nhưng chủ yếu sử dụng cây này để giữ lại các dữ liệu đầu vào và đầu ra của bố cục. Đối với đầu ra, chúng ta tạo một đối tượng không thể thay đổi hoàn toàn mới được gọi là cây mảnh.

Cây mảnh.

Tôi đã đề cập đến cây mảnh không thể thay đổi trước đây, mô tả cách cây này được thiết kế để tái sử dụng các phần lớn của cây trước đó cho bố cục tăng dần.

Ngoài ra, chúng ta lưu trữ đối tượng các ràng buộc mẹ đã tạo ra mảnh đó. Chúng tôi sử dụng khoá này làm khoá bộ nhớ đệm mà chúng tôi sẽ thảo luận thêm ở bên dưới.

Thuật toán bố cục (văn bản) cùng dòng cũng được viết lại để phù hợp với kiến trúc bất biến mới. Thư viện này không chỉ tạo nội dung biểu diễn danh sách phẳng không thể thay đổi cho bố cục cùng dòng, mà còn có tính năng lưu vào bộ nhớ đệm ở cấp đoạn để bố cục lại nhanh hơn, hình dạng cho mỗi đoạn để áp dụng tính năng phông chữ cho các thành phần và từ, thuật toán hai chiều mới của Unicode sử dụng ICU, nhiều bản sửa lỗi về độ chính xác và nhiều tính năng khác.

Các loại lỗi bố cục

Nhìn chung, lỗi bố cục được chia thành 4 loại, mỗi loại có nguyên nhân gốc khác nhau.

Tính chính xác

Khi nghĩ về lỗi trong hệ thống hiển thị, chúng ta thường liên quan đến tính chính xác. Ví dụ: "Trình duyệt A có hành vi X, trong khi Trình duyệt B có hành vi Y" hoặc "Trình duyệt A và B đều bị hỏng". Trước đây, đây là công việc chúng tôi dành rất nhiều thời gian và trong quá trình đó, chúng tôi liên tục đấu tranh với hệ thống. Một chế độ lỗi phổ biến là áp dụng bản sửa lỗi rất nhắm mục tiêu cho một lỗi, nhưng phát hiện thấy nhiều tuần sau đó chúng tôi gây ra sự hồi quy ở một phần khác (dường như không liên quan) của hệ thống.

Như đã mô tả trong các bài đăng trước, đây là dấu hiệu của một hệ thống rất dễ hỏng. Riêng đối với bố cục, chúng ta không có hợp đồng rõ ràng giữa bất kỳ lớp nào, khiến các kỹ sư trình duyệt phải phụ thuộc vào trạng thái không được phép, hoặc hiểu sai một số giá trị từ một phần khác của hệ thống.

Ví dụ: tại một thời điểm nào đó, chúng tôi có một chuỗi khoảng 10 lỗi trong hơn một năm, liên quan đến bố cục linh hoạt. Mỗi bản sửa lỗi gây ra một vấn đề về độ chính xác hoặc hiệu suất trong một phần hệ thống, dẫn đến một lỗi khác.

Giờ đây, LayoutNG xác định rõ ràng hợp đồng giữa tất cả các thành phần trong hệ thống bố cục, chúng tôi nhận thấy rằng có thể áp dụng các thay đổi một cách tự tin hơn nhiều. Chúng tôi cũng được hưởng lợi rất nhiều từ dự án Kiểm thử nền tảng web (WPT) xuất sắc, cho phép nhiều bên đóng góp vào một bộ kiểm thử web chung.

Hiện nay, chúng tôi thấy rằng nếu chúng tôi phát hành một phiên hồi quy thực sự trên kênh chính thức của mình, thì thường thì kênh đó sẽ không có bài kiểm thử liên quan nào trong kho lưu trữ WPT và không phải do hiểu sai các hợp đồng thành phần. Ngoài ra, trong chính sách sửa lỗi, chúng tôi luôn thêm thử nghiệm WPT mới để đảm bảo rằng sẽ không có trình duyệt nào mắc lỗi tương tự.

Chưa hết hiệu lực

Nếu bạn gặp một lỗi bí ẩn, đó là việc thay đổi kích thước cửa sổ trình duyệt hoặc bật/tắt thuộc tính CSS có thể khiến lỗi đó biến mất một cách kỳ diệu, thì bạn đã gặp phải vấn đề về kết quả không hợp lệ. Trên thực tế, một phần của cây có thể thay đổi đã được coi là sạch, nhưng do một số thay đổi trong các ràng buộc gốc nên nó không thể hiện đầu ra chính xác.

Điều này rất phổ biến với các chế độ bố cục 2 lượt (đi bộ cây bố cục hai lần để xác định trạng thái bố cục cuối cùng) như mô tả dưới đây. Trước đây, mã của chúng tôi sẽ có dạng như sau:

if (/* some very complicated statement */) {
  child->ForceLayout();
}

Cách khắc phục cho loại lỗi này thường là:

if (/* some very complicated statement */ ||
    /* another very complicated statement */) {
  child->ForceLayout();
}

Việc khắc phục loại vấn đề này thường gây ra sự sụt giảm hiệu suất nghiêm trọng, (xem việc tình trạng không hợp lệ quá mức bên dưới) và rất khó để khắc phục.

Hôm nay (như mô tả ở trên), chúng ta có một đối tượng ràng buộc mẹ không thể thay đổi mô tả tất cả đầu vào từ bố cục mẹ đến bố cục con. Chúng ta lưu trữ thông tin này bằng mảnh không thể thay đổi thu được. Do đó, chúng tôi có một nơi tập trung để phân biệt 2 thông tin đầu vào này để xác định xem liệu phần tử con có cần thực hiện một lượt truyền bố cục khác hay không. Logic so sánh này phức tạp nhưng đầy đủ. Việc gỡ lỗi lớp vấn đề không hợp lệ này thường dẫn đến việc kiểm tra hai đầu vào theo cách thủ công và quyết định những gì trong đầu vào đã thay đổi sao cho cần phải truyền bố cục khác.

Cách khắc phục mã khác biệt này thường đơn giản và dễ dàng có thể kiểm thử theo đơn vị do tính đơn giản của việc tạo các đối tượng độc lập này.

So sánh hình ảnh có chiều rộng cố định và chiều rộng phần trăm cố định.
Phần tử chiều rộng/chiều cao cố định không quan tâm đến việc kích thước có sẵn được cấp cho phần tử đó có tăng hay không. Tuy nhiên, chiều rộng/chiều cao dựa trên tỷ lệ phần trăm thì sẽ quan tâm. Kích thước có sẵn được thể hiện trên đối tượng Hạn chế mẹ và là một phần của thuật toán so sánh sẽ thực hiện việc tối ưu hoá này.

Mã so sánh cho ví dụ trên là:

if (width.IsPercent()) {
  if (old_constraints.WidthPercentageSize() 
    != new_constraints.WidthPercentageSize())
   return kNeedsLayout;
}
if (height.IsPercent()) {
  if (old_constraints.HeightPercentageSize() 
    != new_constraints.HeightPercentageSize())
   return kNeedsLayout;
}

Hoãn

Loại lỗi này tương tự như tình trạng không hợp lệ quá mức. Về cơ bản, trong hệ thống trước đây, rất khó để đảm bảo rằng bố cục là không thay đổi – điều đó nghĩa là chạy lại bố cục với các đầu vào tương tự, dẫn đến cùng một đầu ra.

Trong ví dụ dưới đây, chúng tôi chỉ đơn giản là chuyển đổi một thuộc tính CSS qua lại giữa hai giá trị. Tuy nhiên, điều này dẫn đến một hình chữ nhật "tăng trưởng vô hạn".

Video và bản minh hoạ cho thấy một lỗi trì hoãn trong Chrome 92 trở xuống. Vấn đề này đã được khắc phục trong Chrome 93.

Với cây có thể thay đổi trước đây, bạn có thể dễ dàng tạo ra các lỗi như thế này. Nếu mã mắc lỗi khi đọc kích thước hoặc vị trí của một đối tượng vào thời điểm hoặc giai đoạn không chính xác (ví dụ: chúng tôi không "xoá" kích thước hoặc vị trí trước đó), chúng tôi sẽ ngay lập tức thêm một lỗi trễ nhỏ. Những lỗi này thường không xuất hiện trong quá trình kiểm thử vì phần lớn các lượt kiểm thử đều tập trung vào một bố cục và lượt kết xuất duy nhất. Điều đáng quan tâm hơn nữa, chúng tôi biết rằng cần có một số độ trễ này để một số chế độ bố cục hoạt động chính xác. Chúng tôi đã gặp lỗi khi thực hiện tối ưu hoá để xoá lượt bố cục nhưng tạo ra "lỗi" vì chế độ bố cục cần hai lượt truyền để có được kết quả chính xác.

Một cây minh hoạ các vấn đề được mô tả trong văn bản trước.
Tuỳ thuộc vào thông tin kết quả bố cục trước đó, các bố cục không thay đổi giá trị

Với LayoutNG, vì chúng ta có cấu trúc dữ liệu đầu vào và đầu ra rõ ràng và việc truy cập vào trạng thái trước đó không được phép, nên chúng ta đã giảm thiểu chung loại lỗi này từ hệ thống bố cục.

Hiệu suất và hiệu suất quá không hợp lệ

Điều này trái ngược trực tiếp với lớp lỗi không hợp lệ thấp. Thông thường, khi khắc phục một lỗi có hiệu suất thấp, chúng tôi sẽ kích hoạt một đợt giảm hiệu suất.

Chúng tôi thường phải đưa ra những lựa chọn khó khăn ưu tiên tính chính xác hơn là hiệu suất. Trong phần tiếp theo, chúng ta sẽ tìm hiểu sâu hơn về cách chúng tôi giảm thiểu các loại vấn đề về hiệu suất này.

Sự nổi bật của bố cục hai đường chuyền và các vách đá biểu diễn

Bố cục linh hoạt và bố cục lưới thể hiện sự thay đổi về tính biểu đạt của bố cục trên web. Tuy nhiên, về cơ bản, các thuật toán này khác với thuật toán bố cục khối xuất hiện trước đó.

Bố cục khối (trong hầu hết các trường hợp) chỉ yêu cầu công cụ thực hiện bố cục trên tất cả các thành phần con đúng một lần. Điều này rất tốt cho hiệu suất, nhưng rốt cuộc không thể hiện được như nhà phát triển web mong muốn.

Ví dụ: thường thì bạn muốn mở rộng kích thước của tất cả thành phần con cháu bằng kích thước lớn nhất. Để hỗ trợ việc này, bố cục mẹ (linh hoạt hoặc lưới) sẽ thực hiện lượt đo lường để xác định kích thước của từng phần tử con, sau đó truyền bố cục để kéo dài tất cả phần tử con tới kích thước này. Đây là chế độ mặc định cho cả bố cục linh hoạt và bố cục lưới.

Hai tập hợp hộp, hộp đầu tiên cho thấy kích thước nội tại của các hộp trong lượt đo lường, tập thứ hai với bố cục tất cả chiều cao bằng nhau.

Các bố cục 2 lượt này ban đầu chấp nhận được về mặt hiệu suất, vì mọi người thường không lồng ghép sâu chúng. Tuy nhiên, chúng tôi bắt đầu nhận thấy các vấn đề nghiêm trọng về hiệu suất vì xuất hiện nhiều nội dung phức tạp hơn. Nếu bạn không lưu kết quả của giai đoạn đo lường vào bộ nhớ đệm, cây bố cục sẽ chuyển đổi giữa trạng thái measure (đo lường) và trạng thái bố cục cuối cùng.

Bố cục 1, 2 và 3 lượt giải thích trong phần chú thích.
Trong hình trên, chúng ta có 3 phần tử <div>. Bố cục một luồng đơn giản (như bố cục khối) sẽ truy cập vào ba nút bố cục (độ phức tạp O(n)). Tuy nhiên, đối với bố cục 2 luồng (chẳng hạn như linh hoạt hoặc lưới), điều này có thể dẫn đến sự phức tạp của lượt truy cập O(2n) trong ví dụ này.
Biểu đồ cho thấy thời gian bố cục tăng theo cấp số nhân.
Hình ảnh và bản minh hoạ này cho thấy một bố cục luỹ thừa với bố cục Lưới. Vấn đề này được khắc phục trong Chrome 93 do việc chuyển Lưới sang kiến trúc mới

Trước đây, chúng ta cố gắng thêm các bộ nhớ đệm rất cụ thể vào bố cục linh hoạt và bố cục lưới để đối phó với loại vách đá hiệu suất này. Điều này hiệu quả (và chúng tôi đã tiến xa hơn với Flex), nhưng liên tục đấu tranh với các lỗi hết hiệu lực và vô hiệu hoá.

LayoutNG cho phép chúng ta tạo cấu trúc dữ liệu rõ ràng cho cả đầu vào và đầu ra của bố cục, trên hết, chúng tôi đã tạo bộ nhớ đệm cho hoạt động đo lường và truyền bố cục. Điều này đưa độ phức tạp trở lại O(n), dẫn đến hiệu suất tuyến tính như dự đoán cho các nhà phát triển web. Nếu từng xảy ra trường hợp bố cục đang thực hiện bố cục 3 lượt, chúng ta cũng sẽ lưu lượt truyền đó vào bộ nhớ đệm. Điều này có thể mở ra cơ hội đưa các chế độ bố cục nâng cao hơn vào một cách an toàn. Đây là ví dụ về cách cơ bản mà RenderingNG mở ra khả năng mở rộng trên mọi nền tảng. Trong một số trường hợp, bố cục Lưới có thể đòi hỏi bố cục 3 lượt, nhưng hiện tại trường hợp này cực kỳ hiếm.

Chúng tôi nhận thấy khi nhà phát triển gặp vấn đề về hiệu suất, cụ thể là về bố cục, thường là do lỗi thời gian bố cục theo cấp số nhân thay vì công suất thô của giai đoạn bố cục của quy trình. Nếu thay đổi gia tăng nhỏ (một phần tử thay đổi một thuộc tính css) dẫn đến bố cục 50-100 mili giây, thì đây có thể là lỗi bố cục luỹ thừa.

Tóm tắt

Bố cục là một khu vực cực kỳ phức tạp và chúng tôi đã không đề cập đến tất cả các loại chi tiết thú vị như tối ưu hoá bố cục cùng dòng (thực sự là toàn bộ hệ thống phụ văn bản và nội tuyến hoạt động) và thậm chí cả các khái niệm được đề cập ở đây chỉ thực sự mới mẻ và đã đề cập đến nhiều chi tiết. Tuy nhiên, chúng tôi hy vọng rằng về lâu dài, việc cải thiện kiến trúc một cách có hệ thống có thể mang lại thành quả vượt trội như thế nào.

Dù vậy, chúng tôi biết rằng vẫn còn rất nhiều việc phải làm ở phía trước. Chúng tôi đã biết về các loại vấn đề (cả hiệu suất và độ chính xác) mà chúng tôi đang nỗ lực giải quyết và rất vui mừng về các tính năng bố cục mới sẽ có trong CSS. Chúng tôi tin rằng kiến trúc của LayoutNG giúp cho việc giải quyết những vấn đề này trở nên an toàn và dễ xử lý.

Một hình ảnh (bạn biết đó là hình ảnh nào!) của Una Kravets.