Giới thiệu
Bạn muốn ứng dụng web của mình phản hồi và mượt mà khi tạo ảnh động, hiệu ứng chuyển đổi 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ể tạo ra sự khác biệt giữa cảm giác "gốc" và cảm giác cồng kềnh, chưa được đánh bóng.
Đây là bài viết đầu tiên trong loạt bài viết về việc tối ưu hoá hiệu suất kết xuất trong trình duyệt. Để bắt đầu, chúng ta sẽ tìm hiểu lý do tạo ảnh động mượt mà là một việc khó khăn và những điều cần làm để đạt được điều đó, cũng như một số phương pháp hay nhất và dễ dàng. 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 tính năng đồng bộ hoá V-sync
Người chơi trên máy tính có thể quen thuộc 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 của điện thoại: 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!) khoảng 60 lần/giây. V-sync (hoặc đồng bộ hoá theo chiều dọc) là phương pháp 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 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 đó để hiển thị trên màn hình. Chúng ta muốn nội dung khung hình được lưu vào bộ đệ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 và một nửa khung hình khác, dẫn đến hiện tượng "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 tác động lớn: thời gian kết xuất khung (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 lượng trình duyệt cần để tạo 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 thành một khung hình (~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 được hiển thị trê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
mỗi 16 mili giây để tạo ảnh động. Đây là vấn đề vì nhiều lý do (và chúng ta sẽ thảo luận thêm về vấn đề này trong giây lát), nhưng đặc biệt đáng lo ngại là:
- Độ phân giải của bộ hẹn giờ từ JavaScript chỉ ở mức vài mili giây
- Các thiết bị có tốc độ làm mới khác nhau
Hãy nhớ vấn đề về thời gian khung hình được đề cập ở trên: bạn cần có một khung ảnh động hoàn chỉnh, hoàn tất mọi JavaScript, thao tác DOM, bố cục, vẽ, v.v. để sẵn sàng trước khi quá trình làm mới màn hình tiếp theo diễn ra. Độ phân giải bộ hẹn giờ thấp có thể khiến bạn khó hoàn tất các khung ảnh động trước khi màn hình làm mới lần tiếp theo, nhưng sự khác biệt về tốc độ làm mới màn hình khiến bạn không thể làm được điều này với 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 ra khỏi khoảng thời gian tính giờ cho một khung hình và cuối cùng sẽ bỏ lỡ một khung hình. Điều này sẽ xảy ra ngay cả khi bộ hẹn giờ được kích hoạt với độ chính xác đến mili giây, nhưng thực tế thì không (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 cắm sạc, 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 (ví dụ: cứ 16 khung hình thì bạn bị trễ 1 mili giây), bạn sẽ nhận thấy: bạn sẽ bị bỏ lỡ 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 làm lãng phí điện năng và thời gian CPU mà bạn có thể dành để làm những việc khác trong ứng dụng.
Các màn hình khác nhau có tốc độ làm mới khác nhau: 60Hz là tốc độ phổ biến, nhưng một số điện thoại có tốc độ 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 có tốc độ 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 độ biến thiên có thể là vấn đề lớn hơn. Mắt chúng ta nhận thấy những điểm nhỏ, không đều trong ảnh động mà ảnh động có thời gian không chính xác có thể tạo ra.
Cách để lấy 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 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 các thẻ ở chế độ nền sẽ 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ý việc 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 ả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ù làm giảm tốc độ khung hình xuống một nửa, nhưng điều này giúp ảnh động nhất quán – và như đã nêu ở trên, mắt chúng ta thích ứng với sự biến thiên hơn là tốc độ khung hình. Tốc độ 30Hz ổn định trông sẽ đẹp hơn tốc độ 60Hz bị thiếu 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ừ creative JS để biết thêm thông tin về requestAnimationFrame
. Tuy nhiên, đâ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. Điều này có nghĩa là nếu JavaScript bên trong lệnh gọi lại requestAnimationFrame
mất nhiều 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 cho tính năng đồng bộ hoá v!
16 mili giây không phải là khoảng thời gian dài. May mắn là Công cụ dành 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.
Việc mở dòng thời gian của Công cụ dành cho nhà phát triển và ghi lại ảnh động này trong thực tế cho thấy chúng ta đã 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:
Các lệnh gọi lại requestAnimationFrame (rAF) đó mất hơn 200 mili giây. Đó là một thứ tự quá lớn để đánh dấu một khung hình mỗi 16 mili giây! Việc mở một trong các lệnh gọi lại rAF dài đó sẽ cho 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 chính ở đây là bạn có thể tìm hiểu lệnh gọi lại và điều tra xem điều gì đang mất nhiều thời gian đến vậy.
Hãy lưu ý thời gian kết xuất khung hình là 16 mili giây. Không gian trống đó trong các khung là khoảng không mà bạn có thể làm thêm việc (hoặc để trình duyệt làm việc cần thiết trong nền). Không gian trống đó là một điều tốt.
Các 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 do JavaScript cung cấp là các nội dung khác có thể cản trở lệnh gọi lại rAF và thậm chí ngăn lệnh gọi đó 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 (chẳng hạn như xử lý một XHR vừa đế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ộ hẹn giờ) có thể đột nhiên xuất hiện và chạy trong bất kỳ khoảng thời gian nào mà không cần nhường quyền. 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ẽ bị đình trệ hoàn toàn. Chúng tôi gọi những hiện tượng giật ảnh động đó là 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 số phương pháp hay nhất về cấu trúc để giúp bạn gặt hái 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 quá trình xử lý onscroll là một nguyên nhân rất phổ biến gây ra hiện tượng giật dữ dội.
- Đẩy nhiều quá trình xử lý (tức là mọi thứ sẽ mất nhiều thời gian để chạy) vào lệnh gọi lại rAF hoặc Trình chạy web càng nhiều càng tốt.
- Nếu đẩ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 đó để bạn chỉ xử lý một chút mỗi khung hình hoặc trì hoãn công việc đó cho đến khi một ả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 cách mượt mà.
Để biết hướng dẫn tuyệt vời 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 Ảnh động gọn nhẹ, hiệu quả và nhanh hơn với requestAnimationFrame của Paul Lewis.
Ảnh động CSS
Điều gì tốt hơn JS gọn nhẹ trong sự kiện và lệnh gọi lại rAF? Không có JS.
Trước đó, chúng tôi đã nói rằng không có giải pháp toàn diện 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 để hoàn toàn không cần đến các lệnh gọi lại đó. Cụ thể là trên Chrome dành cho Android (và các trình duyệt khác đang phát triển các tính năng tương tự), ảnh động CSS có một thuộc tính rất mong muốn là trình duyệt thường có thể chạy ảnh động ngay cả khi JavaScript đang chạy.
Có một câu nhận định ngầm ẩn 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 hiệu quả: tại bất kỳ thời điểm nào, trình duyệt có thể đang chạy JS, thực hiện bố cục hoặc vẽ, nhưng chỉ một thao tá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ụ dành cho nhà phát triển. Một trong những trường hợp ngoại lệ đối với quy tắc này là ảnh động CSS trên Chrome cho Android (và sắp tới là Chrome cho máy tính, mặc dù hiện chưa có).
Khi có thể, hãy sử dụng ảnh động CSS để đơn giản hoá ứng dụng và cho phép ảnh động chạy trơn tru, ngay cả khi JavaScript đang 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. Tuy nhiên, nếu chúng ta điều khiển ảnh động đó bằng ảnh động CSS, 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, ảnh động CSS chỉ không bị giật trên Chrome cho Android chứ không phải Chrome dành 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.
Kết thúc
Tóm lại:
- 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 Vsync tạo ra có tác động tích cực rất lớn đến cảm nhận của người dùng về ứng dụng.
- Cách tốt nhất để có ả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.
- Để ảnh động rAF luôn hoạt động tốt, 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 và giữ cho lệnh gọi lại rAF ngắn (<15 mili giây).
Cuối cùng, ảnh động 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í 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 sâu về hiệu suất cuộn dựa trên các khái niệm này.
Chúc bạn tạo ảnh động vui vẻ!