Loại bỏ hiện tượng giật để cải thiện hiệu suất hiển thị

Tom Wiltzius
Tom Wiltzius

Giới thiệu

Bạn muốn ứng dụng web của mình hoạt động mượt mà và thích ứng nhanh khi thực hiện ảnh động, hiệu ứng chuyển đổi và các hiệu ứng giao diện người dùng nhỏ khác. Việc đảm bảo các hiệu ứng này không bị giật có thể tạo ra sự khác biệt giữa cảm giác "gốc" hoặc cảm giác thô, chưa được trau chuốt.

Đây là bài viết đầu tiên trong loạt bài viết về cách tối ưu hoá hiệu suất hiển thị trong trình duyệt. Để bắt đầu, chúng ta sẽ tìm hiểu lý do khiến việc tạo ảnh động mượt mà trở nên khó khăn và những việc cần làm để đạt được điều đó, cũng như một vài phương pháp hay nhất và dễ thực hiện. Nhiều ý tưởng trong số này ban đầu được trình bày trong "Jank Busters", một bài nói chuyện mà Nat Duca và tôi đã trình bày tại Google I/O (video) năm nay.

Giới thiệu về V-sync

Người chơi trò chơi trên máy tính có thể đã quen với thuật ngữ này, nhưng thuật ngữ này không phổ biến trên web: v-sync là gì?

Hãy xem xét màn hình điện thoại của bạn: màn hình này làm mới theo một khoảng thời gian đều đặn, thường là (nhưng không phải lúc nào cũng vậy!) khoảng 60 lần một giây. V-sync (hoặc đồng bộ hoá dọc) là việc chỉ tạo khung hình mới giữa các lần làm mới màn hình. Bạn có thể coi đây là một tình huống tương tranh giữa quy trình ghi dữ liệu vào vùng đệm màn hình và hệ điều hành đọc dữ liệu đó để đưa lên màn hình. Chúng tôi muốn nội dung khung hình được lưu vào vùng đệm thay đổi giữa các lần làm mới này, chứ không phải trong khi làm mới; nếu không, màn hình sẽ hiển thị một nửa khung hình này và một nửa khung hình khác, dẫn đến hiện tượng "bị rách".

Để có ảnh động mượt mà, bạn cần có một khung hình mới sẵn sàng mỗi khi màn hình làm mới. Điều này có hai ý nghĩa lớn: thời gian kết xuất khung hình (tức là thời điểm khung hình cần sẵn sàng) và ngân sách khung hình (tức là thời gian trình duyệt phải tạo ra một khung hình). Bạn chỉ có thời gian giữa các lần làm mới màn hình để hoàn tất một khung hình (khoảng 16 mili giây trên màn hình 60 Hz) và bạn muốn bắt đầu tạo khung hình tiếp theo ngay khi khung hình cuối cùng xuất hiện trên màn hình.

Thời gian là yếu tố then chốt: requestAnimationFrame

Nhiều nhà phát triển web sử dụng setInterval hoặc setTimeout mỗi 16 mili giây để tạo ảnh động. Đây là một vấn đề vì nhiều lý do (chúng ta sẽ thảo luận thêm sau một phút nữa), nhưng điều đáng lo ngại nhất là:

  • Độ phân giải của bộ hẹn giờ từ JavaScript chỉ ở mức vài mili giây
  • Mỗi thiết bị có tốc độ làm mới khác nhau

Hãy nhớ lại vấn đề về thời gian kết xuất khung hình đã đề cập ở trên: bạn cần có một khung hình ảnh động hoàn chỉnh, hoàn tất với mọi JavaScript, thao tác DOM, bố cục, vẽ, v.v. để sẵn sàng trước khi màn hình tiếp theo được làm mới. Độ phân giải bộ hẹn giờ thấp có thể khiến bạn khó hoàn tất các khung hình ảnh động trước lần làm mới màn hình tiếp theo, nhưng sự thay đổi về tốc độ làm mới màn hình khiến bạn không thể thực hiện việc này bằng bộ hẹn giờ cố định. Bất kể khoảng thời gian của bộ hẹn giờ là bao nhiêu, bạn sẽ dần lệch khỏi khoảng thời gian của một khung hình và cuối cùng là bỏ qua một khung hình. Điều này sẽ xảy ra ngay cả khi bộ hẹn giờ kích hoạt với độ chính xác đến từng mili giây, nhưng thực tế không phải vậy (như các nhà phát triển đã phát hiện) – độ phân giải của bộ hẹn giờ thay đổi tuỳ thuộc vào việc máy đang dùng pin hay đang cắm điện, có thể bị ảnh hưởng bởi các thẻ nền chiếm dụng tài nguyên, v. v. Ngay cả khi điều này hiếm khi xảy ra (giả sử, cứ sau 16 khung hình vì bạn bị lệch một mili giây), bạn sẽ nhận thấy: bạn sẽ bị rớt vài khung hình mỗi giây. Bạn cũng sẽ phải tạo các khung hình không bao giờ hiển thị, điều này gây lãng phí điện năng và thời gian của CPU mà bạn có thể dành để làm những việc khác trong ứng dụng.

Mỗi màn hình có tốc độ làm mới khác nhau: 60 Hz là phổ biến, nhưng một số điện thoại là 59 Hz, một số máy tính xách tay giảm xuống 50 Hz ở chế độ tiết kiệm pin, một số màn hình máy tính là 70 Hz.

Chúng ta thường tập trung vào số khung hình/giây (FPS) khi thảo luận về hiệu suất kết xuất, nhưng phương sai có thể là một vấn đề thậm chí còn lớn hơn. Mắt chúng ta nhận thấy những điểm bất thường nhỏ trong ảnh động mà một ảnh động có thời gian kém có thể tạo ra.

Cách để có được khung ảnh động đúng thời gian là sử dụng requestAnimationFrame. Khi sử dụng API này, bạn đang yêu cầu trình duyệt cung cấp một khung ảnh động. Lệnh gọi lại của bạn sẽ được gọi khi trình duyệt sắp tạo một khung hình mới. Điều này xảy ra bất kể tốc độ làm mới là bao nhiêu.

requestAnimationFrame cũng có các thuộc tính hay khác:

  • Ảnh động trong các thẻ nền sẽ tạm dừng, giúp tiết kiệm tài nguyên hệ thống và thời lượng pin.
  • Nếu hệ thống không thể xử lý việc kết xuất ở tốc độ làm mới của màn hình, thì hệ thống có thể điều tiết ảnh động và tạo lệnh gọi lại ít thường xuyên hơn (ví dụ: 30 lần/giây trên màn hình 60 Hz). Mặc dù điều này làm giảm một nửa tốc độ khung hình, nhưng nó giữ cho ảnh động nhất quán – và như đã nêu ở trên, mắt chúng ta nhạy cảm với sự biến thiên hơn nhiều so với tốc độ khung hình. Tốc độ khung hình ổn định ở mức 30 Hz trông sẽ mượt hơn tốc độ 60 Hz nhưng bị mất vài khung hình mỗi giây.

requestAnimationFrame đã được thảo luận ở khắp mọi nơi, vì vậy, hãy tham khảo các bài viết như bài viết này từ JS sáng tạo để biết thêm thông tin về bài viết này, nhưng đây là bước đầu tiên quan trọng để tạo ảnh động mượt mà.

Ngân sách khung hình

Vì chúng ta muốn có một khung hình mới sẵn sàng cho mỗi lần làm mới màn hình, nên chỉ có thời gian giữa các lần làm mới để thực hiện tất cả công việc tạo khung hình mới. Trên màn hình 60Hz, điều đó có nghĩa là chúng ta có khoảng 16 mili giây để chạy tất cả JavaScript, thực hiện bố cục, vẽ và bất kỳ việc gì khác mà trình duyệt phải làm để hiển thị khung hình. Điều này có nghĩa là nếu JavaScript bên trong lệnh gọi lại requestAnimationFrame của bạn mất hơn 16 mili giây để chạy, thì bạn không thể tạo khung hình kịp thời cho v-sync!

16 mili giây không phải là nhiều thời gian. May mắn là Công cụ cho nhà phát triển của Chrome có thể giúp bạn theo dõi xem bạn có đang vượt quá ngân sách khung hình trong lệnh gọi lại requestAnimationFrame hay không.

Khi mở dòng thời gian của Công cụ cho nhà phát triển và ghi lại ảnh động này, chúng ta có thể nhanh chóng thấy rằng chúng ta đang vượt quá ngân sách khi tạo ảnh động. Trong Dòng thời gian, hãy chuyển sang "Khung" và xem:

Bản minh hoạ có quá nhiều bố cục
Bản minh hoạ có quá nhiều bố cục

Những lệnh gọi lại requestAnimationFrame (rAF) đó mất hơn 200 mili giây. Đó là một khoảng thời gian quá dài để đánh dấu một khung hình sau mỗi 16 mili giây! Khi mở một trong những lệnh gọi lại rAF dài đó, bạn sẽ thấy những gì đang diễn ra bên trong: trong trường hợp này, có rất nhiều bố cục.

Video của Paul trình bày chi tiết hơn về nguyên nhân cụ thể của việc bố cục lại (đang đọc scrollTop) và cách tránh việc này. Nhưng điểm mấu chốt ở đây là bạn có thể tìm hiểu sâu về lệnh gọi lại và điều tra xem điều gì đang mất nhiều thời gian.

Bản minh hoạ mới có bố cục gọn gàng hơn nhiều
Bản minh hoạ mới nhất với bố cục được thu gọn đáng kể

Hãy lưu ý thời gian kết xuất khung hình là 16 mili giây. Khoảng trống trong khung hình là khoảng trống bạn phải làm thêm (hoặc để trình duyệt thực hiện công việc cần làm trong nền). Khoảng trống đó là một điều tốt.

Nguồn gây hiện tượng giật khác

Nguyên nhân lớn nhất gây ra sự cố khi cố gắng chạy ảnh động dựa trên JavaScript là các nội dung khác có thể cản trở lệnh gọi lại rAF của bạn, thậm chí ngăn lệnh gọi lại này chạy. Ngay cả khi lệnh gọi lại rAF của bạn gọn nhẹ và chỉ chạy trong vài mili giây, các hoạt động khác (như xử lý XHR vừa đến, chạy trình xử lý sự kiện đầu vào hoặc chạy các bản cập nhật theo lịch trên bộ hẹn giờ) có thể đột ngột xuất hiện và chạy trong bất kỳ khoảng thời gian nào mà không mang lại kết quả. Trên thiết bị di động, đôi khi việc xử lý các sự kiện này có thể mất hàng trăm mili giây, trong thời gian đó, ảnh động của bạn sẽ hoàn toàn bị đình trệ. Chúng tôi gọi những sự cố ảnh động đó là hiện tượng giật.

Không có giải pháp thần kỳ nào để tránh những tình huống này, nhưng có một vài phương pháp hay nhất về cấu trúc giúp bạn thành công:

  • Đừng xử lý quá nhiều trong trình xử lý đầu vào! Việc thực hiện nhiều JS hoặc cố gắng sắp xếp lại toàn bộ trang trong khi trình xử lý onscroll là một nguyên nhân rất phổ biến gây ra tình trạng giật hình nghiêm trọng.
  • Đẩy càng nhiều quá trình xử lý (đọc: bất cứ thứ gì mất nhiều thời gian để chạy) vào lệnh gọi lại rAF hoặc Web Workers càng tốt.
  • Nếu bạn đẩy công việc vào lệnh gọi lại rAF, hãy cố gắng chia nhỏ công việc để chỉ xử lý một chút mỗi khung hình hoặc trì hoãn cho đến khi ảnh động quan trọng kết thúc. Bằng cách này, bạn có thể tiếp tục chạy các lệnh gọi lại rAF ngắn và tạo ảnh động mượt mà.

Để xem hướng dẫn chi tiết về cách đẩy quá trình xử lý vào lệnh gọi lại requestAnimationFrame thay vì trình xử lý đầu vào, hãy xem bài viết Leaner, Meaner, Faster Animations with requestAnimationFrame (Ảnh động gọn gàng hơn, mượt mà hơn, nhanh hơn bằng requestAnimationFrame) của Paul Lewis.

Ảnh động CSS

Còn gì tuyệt vời hơn JS gọn nhẹ trong các lệnh gọi lại sự kiện và rAF? Không có JS.

Trước đó, chúng tôi đã nói rằng không có giải pháp nào để tránh làm gián đoạn lệnh gọi lại rAF, nhưng bạn có thể sử dụng ảnh động CSS để tránh hoàn toàn nhu cầu về lệnh gọi lại rAF. Đặc biệt là trên Chrome dành cho Android (và các trình trình duyệt khác đang phát triển các tính năng tương tự), ảnh động CSS có thuộc tính rất mong muốn là trình duyệt thường có thể chạy chúng ngay cả khi JavaScript đang chạy.

Có một câu lệnh ngầm định trong phần trên về hiện tượng giật: trình duyệt chỉ có thể làm một việc tại một thời điểm. Điều này không hoàn toàn đúng, nhưng đây là một giả định hợp lý: tại bất kỳ thời điểm nào, trình duyệt cũng có thể chạy JS, thực hiện bố cục hoặc vẽ, nhưng chỉ một việc tại một thời điểm. Bạn có thể xác minh điều này trong chế độ xem Dòng thời gian của Công cụ cho nhà phát triển. Một trong những trường hợp ngoại lệ của quy tắc này là ảnh động CSS trên Chrome dành cho Android (và sắp tới là Chrome cho máy tính, mặc dù chưa có).

Khi có thể, việc sử dụng ảnh động CSS vừa giúp đơn giản hoá ứng dụng vừa giúp ảnh động chạy mượt mà, ngay cả khi JavaScript chạy.

  // see http://paulirish.com/2011/requestanimationframe-for-smart-animating/ for info on rAF polyfills
  rAF = window.requestAnimationFrame;

  var degrees = 0;
  function update(timestamp) {
    document.querySelector('#foo').style.webkitTransform = "rotate(" + degrees + "deg)";
    console.log('updated to degrees ' + degrees);
    degrees = degrees + 1;
    rAF(update);
  }
  rAF(update);

Nếu bạn nhấp vào nút này, JavaScript sẽ chạy trong 180 mili giây, gây ra hiện tượng giật. Nhưng nếu chúng ta điều khiển ảnh động đó bằng ảnh động CSS, thì hiện tượng giật hình sẽ không còn xảy ra nữa.

(Xin lưu ý rằng tại thời điểm viết bài này, ảnh động CSS chỉ không bị giật trên Chrome dành cho Android, chứ không phải Chrome cho máy tính.)

  /* tools like Modernizr (http://modernizr.com/) can help with CSS polyfills */
  #foo {
    +animation-duration: 3s;
    +animation-timing-function: linear;
    +animation-animation-iteration-count: infinite;
    +animation-animation-name: rotate;
  }

  @+keyframes: rotate; {
    from {
      +transform: rotate(0deg);
    }
    to {
      +transform: rotate(360deg);
    }
  }

Để biết thêm thông tin về cách sử dụng Ảnh động CSS, hãy xem các bài viết như bài viết này trên MDN.

Tóm tắt

Nói ngắn gọn là:

  1. Khi tạo ảnh động, việc tạo khung hình cho mỗi lần làm mới màn hình là rất quan trọng. Ảnh động được đồng bộ hoá theo chiều dọc có tác động tích cực rất lớn đến cảm nhận về ứng dụng.
  2. Cách tốt nhất để có được ảnh động đồng bộ hoá theo chiều dọc trong Chrome và các trình duyệt hiện đại khác là sử dụng ảnh động CSS. Khi bạn cần linh hoạt hơn so với ảnh động CSS, kỹ thuật tốt nhất là ảnh động dựa trên requestAnimationFrame.
  3. Để đảm bảo ảnh động rAF hoạt động ổn định, hãy đảm bảo các trình xử lý sự kiện khác không cản trở lệnh gọi lại rAF đang chạy và giữ cho lệnh gọi lại rAF ngắn (<15 mili giây).

Cuối cùng, ảnh động được đồng bộ hoá theo chiều dọc không chỉ áp dụng cho ảnh động đơn giản trên giao diện người dùng, mà còn áp dụng cho ảnh động Canvas2D, ảnh động WebGL và thậm chí là thao tác cuộn trên các trang tĩnh. Trong bài viết tiếp theo của loạt bài này, chúng ta sẽ tìm hiểu kỹ hơn về hiệu suất cuộn dựa trên những khái niệm này.

Chúc bạn tạo ảnh động vui vẻ!

Tài liệu tham khảo