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 phản hồi nhanh chóng và mượt mà khi thực hiện ảnh động, hiệu ứng chuyển tiếp và các hiệu ứng nhỏ khác trên giao diện người dùng. Việc đảm bảo các hiệu ứng này không bị giật có thể đồng nghĩa với sự khác biệt giữa cảm giác "bản địa" với cảm giác rối rắm, chưa trau chuốt.

Đây là bài viết đầu tiên trong loạt bài viết đề cập đến tính năng tối ưu hoá hiệu suất hiển thị trong trình duyệt. Để bắt đầu, chúng tôi sẽ giải thích lý do khiến ảnh động mượt mà và những gì cần đạt được để đạt được điều đó, cũng như một số 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 bài thuyết trình "Jank Busters", một bài nói chuyện của Nat Duca và tôi đã đưa ra tại buổi trò chuyện tại Google I/O (video) năm nay.

Ra mắt V-sync

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

Hãy cân nhắc đến màn hình điện thoại của bạn: màn hình được làm mới theo định kỳ, thường là (nhưng không phải lúc nào cũng!) khoảng 60 lần một giây. Đồng bộ hoá V (hay đồng bộ hoá theo chiều dọc) đề cập đến 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 điều kiện tranh đấu giữa quá 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 dữ liệu lên màn hình. Chúng ta muốn nội dung khung vùng đệm thay đổi giữa các lần làm mới này, chứ không phải trong lúc 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 "xé hình".

Để có được ảnh động mượt mà, bạn cần có sẵn một khung mới mỗi khi quá trình làm mới màn hình diễn ra. Đ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 khung hình). Bạn chỉ có khoảng thời gian giữa các lần làm mới màn hình để hoàn thành 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 cuối cùng được đưa lên màn hình.

Thời gian là tất cả: requestAnimationFrame

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

  • Độ phân giải bộ tính giờ từ JavaScript chỉ theo thứ tự vài mili giây
  • Các thiết bị khác nhau 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 nêu trên: bạn cần có một khung ảnh động hoàn chỉnh, hoàn chỉnh bằng mọi JavaScript, thao tác DOM, bố cục, tô màu, v.v. để sẵn sàng trước khi lần làm mới màn hình tiếp theo xảy ra. Độ phân giải thấp của bộ tính giờ có thể gây khó khăn cho việc hoàn tất 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 việc này không thể thực hiện được với bộ tính giờ cố định. Bất kể khoảng thời gian hẹn giờ là bao nhiêu, bạn sẽ từ từ thoát ra khỏi cửa sổ thời gian của một khung hình và kết thúc bằng việc thả một khung hình. Điều này sẽ xảy ra ngay cả khi bộ tính giờ kích hoạt với độ chính xác đến từng mili giây (tức là nhà phát triển đã phát hiện ra) – độ phân giải của bộ tính 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. Bạn cũng sẽ thực hiện việc tạo các khung hình không bao giờ hiển thị, việc 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 của mình.

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

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 sự chênh lệch có thể còn là một vấn đề còn lớn hơn nữa. Mắt chúng ta nhận thấy các điểm gờ nhỏ, bất thường trong ảnh động mà ảnh động có thời điểm không đúng thời điểm có thể tạo ra.

Bạn có thể sử dụng requestAnimationFrame để lấy khung hình ảnh động được định giờ chính xác. Khi sử dụng API này, bạn sẽ 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 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 thú vị khác:

  • Ảnh động trong thẻ nền bị 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 không thể xử lý quá trình kết xuất ở tốc độ làm mới của màn hình, hệ thống có thể điều tiết các ảnh động và giảm tần suất gọi lại (ví dụ: 30 lần một giây trên màn hình 60 Hz). Mặc dù tỷ lệ khung hình này giảm một nửa tốc độ khung hình, nhưng vẫn giữ cho hoạt ảnh nhất quán -- và như đã nói ở trên, mắt của chúng ta chú ý đến phương sai nhiều hơn so với tốc độ khung hình. Tốc độ 30 Hz ổn định trông đẹp hơn mức 60 Hz nhưng lỡ mất vài khung hình trong một giây.

requestAnimationFrame đã được thảo luận ở 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 về JS trong mẫu quảng cáo để biết thêm thông tin. Tuy nhiên, đó là bước quan trọng đầu tiên để tạo ảnh động mượt mà.

Ngân sách khung hình

Vì chúng ta muốn một khung mới sẵn sàng mỗi lần làm mới màn hình, nên chỉ còn một khoảng thời gian giữa các lần làm mới để tạo khung 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 cứ việc gì khác mà trình duyệt phải làm để lấy 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 có hy vọng tạo khung hình kịp thời để đồng bộ hoá v-sync!

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

Việc khai thác dòng thời gian của Công cụ cho nhà phát triển và ghi lại hoạt ảnh này trong thực tế sẽ nhanh chóng cho thấy rằng chúng tôi đang vượt 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ó bố cục quá nhiều
Bản minh hoạ có bố cục quá nhiều

Các lệnh gọi lại requestAnimationFrame (rAF) đang mất hơn 200 mili giây. Đó là một thứ tự cường độ quá dài để đánh dấu khung hình cứ 16 mili giây một lần! 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 là 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 là scrollTop) và cách tránh. Tuy nhiên, vấn đề ở đây là bạn có thể tìm hiểu kỹ lệnh gọi lại và tìm hiểu xem điều gì đang mất nhiều thời gian.

Bản minh hoạ mới nhất với bố cục tinh giản hơn nhiều
Bản minh hoạ mới với bố cục rút gọn đáng kể

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

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

Nguyên nhân lớn nhất gây ra sự cố khi cố gắng chạy ảnh động hỗ trợ JavaScript là các nội dung khác có thể gây cản trở cho lệnh gọi lại rAF của bạn và thậm chí là khiến tính năng này không chạy được. Ngay cả khi lệnh gọi lại rAF ít phức tạp và chỉ chạy trong vài mili giây, các hoạt động khác (như xử lý XHR vừa xuất hiện, chạy trình xử lý sự kiện đầu vào hoặc chạy bản cập nhật theo lịch trên bộ tính 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ý những 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ẽ bị dừng hoàn toàn. Chúng tôi gọi những quá trình kéo ảnh động đó là hiện tượng giật.

Không có dấu đầu dòng kỳ diệu nào để tránh những tình huống này, nhưng có một số phương pháp hay nhất về cấu trúc để tạo dựng thành công cho bạn:

  • Không xử lý 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, ví dụ như trình xử lý khi cuộn là nguyên nhân rất phổ biến gây ra tình trạng giật nghiêm trọng.
  • Đẩy nhiều quá trình xử lý (đọc: mọi thao tác mất nhiều thời gian để chạy) vào lệnh gọi lại rAF hoặc Web Workers nhất có thể.
  • Nếu bạn đưa công việc vào lệnh gọi lại rAF, hãy thử phân đoạn để bạn 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 cụ thể về cách đẩy quá trình xử lý vào các lệnh gọi lại requestAnimationFrame thay vì trình xử lý đầu vào, hãy xem bài viết của Paul Lewis Leaner, Meaner, Better Animations with requestAnimationFrame.

Ảnh động CSS

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

Trước đó chúng tôi đã nói không có giải pháp toàn diện để tránh làm gián đoạn lệnh gọi lại rAF của bạn, nhưng bạn có thể sử dụng ảnh động CSS để tránh hoàn toàn nhu cầu sử dụng chúng. Trên Chrome dành cho Android nói riêng (và các trình duyệt khác đang hoạt động với các tính năng tương tự), ảnh động CSS có thuộc tính mong muốn mà trình duyệt thường có thể chạy 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ể thực hiện một thao tác tại một thời điểm. Điều này không hoàn toàn đúng, nhưng đó là một giả định hoạt động tốt: trình duyệt có thể chạy JS, thực hiện bố cục hoặc vẽ tại một thời điểm bất kỳ, nhưng chỉ một lần 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 các ngoại lệ đối với quy tắc này là ảnh động CSS trên Chrome dành cho Android (và sắp tới là Chrome dành 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 của bạn vừa cho phé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 JavaScript sẽ chạy trong 180 mili giây, gây ra hiện tượng giật. Nhưng nếu thay vào đó, chúng ta điều khiển ảnh động đó bằng ảnh động CSS thì hiện tượng giật sẽ không còn xảy ra.

(Hãy nhớ rằng tại thời điểm viết bài này, hoạt ảnh CSS chỉ không bị giật trên Chrome dành cho Android, không phải Chrome dành cho máy tính để bàn.)

  /* 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.

Bản tóm tắt

Nội dung ngắn gọn như sau:

  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 Vsync tạo ra tác động tích cực rất lớn đến cảm nhận của ứng dụng.
  2. Cách tốt nhất để tải ảnh động vsync 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. Để duy trì hiệu ứng chuyển động rAF tốt và ổ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 chạy, đồng thời đảm bảo các lệnh gọi lại rAF ngắn (<15 mili giây).

Cuối cùng, ảnh động của vsync không chỉ áp dụng cho ảnh động giao diện người dùng đơn giản, mà còn áp dụng cho ảnh động Canvas2D, ảnh động WebGL và thậm chí cả 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 khi lưu ý đế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