In-App Review trên iOS: Dùng SKStoreReviewController hay requestReview cho đúng từng phiên bản iOS
Trên Android, việc hiển thị popup xin đánh giá ngay trong ứng dụng do In-App Review API của Google Play đảm nhiệm. Trên iOS, cơ chế tương đương thuộc về StoreKit và đã trải qua bốn thế hệ giao diện lập trình khác nhau, mỗi thế hệ dành cho một khoảng phiên bản hệ điều hành riêng.
Đây chính là chỗ gây nhầm lẫn nhiều nhất: rất nhiều đoạn mã tìm được trên mạng dùng lời gọi đã bị đánh dấu ngừng hỗ trợ, hoặc dùng đúng lời gọi nhưng đặt sai chỗ khiến popup không bao giờ hiện ra. Bài viết này đi hết từ lịch sử API, giới hạn cứng do Apple đặt ra, tới chiến lược chọn thời điểm gọi sao cho vừa tăng lượt đánh giá vừa không vi phạm quy định.
1. Android và iOS: cùng ý tưởng, khác cơ chế
| Tiêu chí | Android (Google Play) | iOS (App Store) |
|---|---|---|
| Thư viện | Play Core Review, thêm qua trình quản lý phụ thuộc | StoreKit, có sẵn trong hệ điều hành |
| Cách hoạt động | Yêu cầu luồng đánh giá trước, sau đó mới khởi chạy | Gọi một lời đề nghị duy nhất, hệ thống tự quyết định |
| Giới hạn tần suất | Do Google kiểm soát, không công bố con số cụ thể | Tối đa ba lần cho mỗi ứng dụng trong vòng ba trăm sáu mươi lăm ngày |
| Người dùng có tắt được không | Không có công tắc riêng | Có, trong phần cài đặt của App Store |
| Biết kết quả người dùng chấm mấy sao | Không | Không |
| Hiện trên bản thử nghiệm nội bộ | Có, nếu cài từ kênh phân phối của chợ ứng dụng | Không hiện trên bản TestFlight |
| Hiện trên bản gỡ lỗi | Không | Có, nhưng đánh giá không được gửi đi |
Khác biệt ở hai dòng cuối rất hay bị hiểu ngược, và là nguyên nhân của phần lớn các báo cáo lỗi giả. Chi tiết ở mục 7.
2. Bốn thế hệ API và chọn cái nào cho dự án của bạn
| Thế hệ | Lời gọi | Áp dụng từ | Trạng thái |
|---|---|---|---|
| Thứ nhất | SKStoreReviewController không tham số | iOS 10.3 | Đã ngừng hỗ trợ, không dùng cho dự án mới |
| Thứ hai | SKStoreReviewController kèm tham số cảnh giao diện | iOS 14 | Còn dùng được nhưng đã bị đánh dấu ngừng hỗ trợ ở các bản iOS gần đây |
| Thứ ba | Biến môi trường requestReview trong SwiftUI | iOS 16 | Cách viết được khuyến nghị cho dự án SwiftUI |
| Thứ tư | AppStore.requestReview kèm tham số cảnh giao diện | iOS 18 | Cách viết được khuyến nghị cho dự án UIKit |
Nguyên tắc chọn rất đơn giản: nếu ứng dụng dựng bằng SwiftUI thì dùng thế hệ thứ ba, nếu dựng bằng UIKit và có hỗ trợ iOS 18 trở lên thì dùng thế hệ thứ tư, còn nếu vẫn phải hỗ trợ các phiên bản cũ hơn thì viết nhánh điều kiện theo phiên bản. Điều quan trọng cần hiểu: cả bốn thế hệ đều dẫn tới cùng một hộp thoại hệ thống và cùng chịu chung giới hạn tần suất. Đổi sang lời gọi mới không làm popup xuất hiện nhiều hơn.
3. Mã nguồn cho SwiftUI
import SwiftUI
import StoreKit
struct CheckoutSuccessView: View {
@Environment(\.requestReview) private var requestReview
@AppStorage("successfulOrders") private var successfulOrders = 0
var body: some View {
VStack {
Text("Đặt hàng thành công")
}
.onAppear {
successfulOrders += 1
if successfulOrders == 3 {
Task {
try? await Task.sleep(for: .seconds(2))
requestReview()
}
}
}
}
}
Hai chi tiết trong đoạn mã trên không phải để cho đẹp. Thứ nhất, lời gọi nằm trong khối chạy sau khi màn hình đã hiện xong và có độ trễ hai giây, để người dùng kịp nhìn thấy kết quả thành công trước khi bị hộp thoại che mất. Thứ hai, điều kiện dùng dấu bằng đúng bằng ba chứ không phải lớn hơn hoặc bằng ba, để tránh gọi lại ở mọi đơn hàng sau đó.
4. Mã nguồn cho UIKit
import StoreKit
import UIKit
enum ReviewPrompter {
static func requestIfAppropriate() {
guard let scene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive })
as? UIWindowScene else { return }
if #available(iOS 18.0, *) {
AppStore.requestReview(in: scene)
} else {
SKStoreReviewController.requestReview(in: scene)
}
}
}
Việc lấy đúng cảnh giao diện đang ở trạng thái hoạt động phía trước là bắt buộc. Nếu bạn lấy phần tử đầu tiên trong danh sách cảnh mà không lọc theo trạng thái, trên iPad chạy nhiều cửa sổ hoặc khi ứng dụng vừa quay lại từ nền, bạn có thể lấy trúng một cảnh không hiển thị và popup sẽ im lặng không xuất hiện.
5. Giới hạn cứng do Apple đặt ra
- Tối đa ba lần mỗi ba trăm sáu mươi lăm ngày cho mỗi ứng dụng trên mỗi thiết bị. Đây là cửa sổ trượt chứ không phải theo năm dương lịch.
- Hệ thống toàn quyền quyết định. Lời gọi của bạn chỉ là một đề nghị. Không có giá trị trả về, không có hàm gọi lại, không có cách nào biết popup đã hiện hay chưa.
- Người dùng tắt được hoàn toàn. Trong phần cài đặt của App Store có công tắc cho phép chặn mọi hộp thoại đánh giá trong ứng dụng.
- Người đã đánh giá phiên bản hiện tại sẽ không thấy popup nữa.
- Không được gọi để phản hồi thao tác của người dùng. Đây là quy định của Apple chứ không phải khuyến nghị: hộp thoại này phải xuất hiện một cách tự nhiên trong luồng sử dụng, không phải sau khi người dùng bấm một nút nào đó. Trường hợp cần nút bấm thì dùng cách ở mục 6.
Hệ quả thực tế của giới hạn ba lần: bạn chỉ có ba cơ hội mỗi năm cho mỗi người dùng, và nếu tiêu phí chúng vào những thời điểm người dùng đang bực bội thì bạn vừa mất cơ hội vừa nhận về đánh giá thấp. Vì vậy phần chiến lược ở mục 8 quan trọng hơn phần mã nguồn rất nhiều.
6. Nút Đánh giá ứng dụng đặt trong màn hình cài đặt
Vì không được gọi hộp thoại hệ thống để phản hồi một cú chạm, nút đánh giá thủ công phải làm theo cách khác: mở thẳng trang ứng dụng trên App Store ở chế độ viết đánh giá.
func openWriteReviewPage(appID: String) {
let urlString = "https://apps.apple.com/app/id" + appID + "?action=write-review"
guard let url = URL(string: urlString) else { return }
UIApplication.shared.open(url)
}
Tham số hành động ở cuối địa chỉ là phần quan trọng: thiếu nó, người dùng chỉ được đưa tới trang giới thiệu ứng dụng và phải tự cuộn xuống tìm chỗ viết đánh giá, tỉ lệ hoàn thành rơi rất mạnh. Trên Android, đường dẫn tương đương là địa chỉ mở trực tiếp trang ứng dụng trong Google Play.
Mô hình kết hợp hiệu quả nhất trong thực tế: dùng hộp thoại hệ thống cho luồng tự động ở những thời điểm đẹp, và đặt thêm một nút đánh giá thủ công trong màn hình cài đặt cho những người dùng chủ động muốn ủng hộ.
7. Tám lý do popup không hiện và cách phân biệt
| Nguyên nhân | Cách nhận biết | Xử lý |
|---|---|---|
| Đang chạy bản TestFlight | Bản cài từ ứng dụng TestFlight | Đây là hành vi đúng, không phải lỗi. Hộp thoại không bao giờ hiện trên bản thử nghiệm qua TestFlight, xem bài TestFlight là gì |
| Đã đủ ba lần trong năm | Thiết bị đã cài và dùng ứng dụng lâu, đã từng thấy popup | Thử trên thiết bị khác hoặc chờ qua cửa sổ thời gian |
| Người dùng đã tắt trong cài đặt | Không bao giờ thấy popup của bất kỳ ứng dụng nào | Kiểm tra công tắc trong phần cài đặt của App Store |
| Lấy sai cảnh giao diện | Mã nguồn lấy phần tử đầu tiên mà không lọc trạng thái | Lọc theo trạng thái hoạt động phía trước như mục 4 |
| Gọi khi ứng dụng chưa ở trạng thái hoạt động | Gọi ngay trong hàm khởi tạo hoặc lúc ứng dụng vừa quay lại từ nền | Dời lời gọi vào sau khi giao diện đã hiện, thêm độ trễ ngắn |
| Gọi không trên luồng chính | Gọi bên trong khối xử lý kết quả mạng | Chuyển lời gọi về luồng chính |
| Bị một lớp phủ khác che mất | Có hộp thoại hoặc màn hình đang trình bày cùng lúc | Chờ lớp phủ kia đóng xong rồi mới gọi |
| Người dùng đã đánh giá phiên bản hiện tại | Tài khoản đã từng viết đánh giá | Đây là hành vi đúng |
8. Chiến lược chọn thời điểm: phần quyết định thành bại
Với ba cơ hội mỗi năm, việc chọn thời điểm quan trọng hơn mọi thứ khác. Ba nguyên tắc:
Nguyên tắc thứ nhất: chỉ hỏi sau một khoảnh khắc thành công
Thời điểm tốt là ngay sau khi người dùng vừa nhận được giá trị: hoàn tất đơn hàng, xuất xong tệp, thắng một màn chơi, hoàn thành chuỗi bảy ngày liên tiếp. Thời điểm tệ là sau khi có lỗi mạng, sau màn hình thanh toán thất bại, hoặc ngay khi vừa mở ứng dụng lần đầu.
Nguyên tắc thứ hai: cộng dồn tín hiệu, không dựa vào một sự kiện
struct ReviewGate {
static func shouldAsk(sessions: Int, successEvents: Int, daysSinceInstall: Int,
lastAskedDaysAgo: Int, hadRecentCrash: Bool) -> Bool {
guard !hadRecentCrash else { return false }
guard daysSinceInstall >= 7 else { return false }
guard sessions >= 5 else { return false }
guard successEvents >= 2 else { return false }
guard lastAskedDaysAgo >= 120 else { return false }
return true
}
}
Điều kiện về sự cố gần đây là chi tiết ít người nghĩ tới nhưng rất đáng giá: xin đánh giá ngay sau khi ứng dụng vừa văng là cách nhanh nhất để nhận một sao. Nếu bạn đang thu thập dữ liệu sự cố, hãy dùng nó làm cổng chặn.
Nguyên tắc thứ ba: giãn cách giữa các lần hỏi
Đặt khoảng cách tối thiểu khoảng bốn tháng giữa hai lần gọi. Ba lần trải đều trong năm luôn tốt hơn ba lần dồn trong một tháng, vì mỗi lần rơi vào một trạng thái tâm lý khác nhau của người dùng.
9. Ranh giới cấm: popup tự chế để lọc đánh giá xấu
Mô hình từng phổ biến một thời: hiện một hộp thoại tự viết hỏi bạn có hài lòng không, ai bấm có thì dẫn sang App Store, ai bấm không thì dẫn sang biểu mẫu góp ý nội bộ. Cả Apple lẫn Google đều xếp cách làm này vào nhóm thao túng đánh giá và ứng dụng có thể bị từ chối hoặc bị gỡ.
Ranh giới rõ ràng:
| Được phép | Không được phép |
|---|---|
| Gọi hộp thoại hệ thống ở thời điểm hợp lý | Dùng popup tự chế để lọc người không hài lòng ra khỏi luồng đánh giá |
| Đặt nút đánh giá thủ công trong màn hình cài đặt | Chặn tính năng cho tới khi người dùng đánh giá |
| Có kênh góp ý nội bộ đặt song song, luôn hiện diện | Tặng vật phẩm, tiền hoặc quyền lợi để đổi lấy đánh giá năm sao |
| Nhắc một lần rồi thôi | Hiện lại liên tục cho tới khi người dùng chịu bấm |
Cách làm đúng và vẫn hiệu quả: đặt kênh góp ý nội bộ ở một chỗ dễ thấy và độc lập, đồng thời trả lời nghiêm túc mọi đánh giá tiêu cực nhận được. Bài cách trả lời đánh giá tiêu cực trên Google Play và App Store nói kỹ phần này, và bài chiến lược tăng điểm đánh giá tự nhiên bàn về bức tranh tổng thể.
10. Flutter: một thư viện cho cả hai nền tảng
import 'package:in_app_review/in_app_review.dart';
class ReviewService {
final InAppReview _inAppReview = InAppReview.instance;
Future<void> askForReview() async {
if (await _inAppReview.isAvailable()) {
await _inAppReview.requestReview();
}
}
// Dùng cho nút Đánh giá ứng dụng đặt trong màn hình cài đặt
Future<void> openStorePage() async {
await _inAppReview.openStoreListing(appStoreId: '1234567890');
}
}
| Hàm | Hành vi trên iOS | Hành vi trên Android |
|---|---|---|
| isAvailable | Kiểm tra phiên bản hệ điều hành có hỗ trợ hay không | Kiểm tra dịch vụ Google Play có sẵn hay không |
| requestReview | Gọi hộp thoại của StoreKit | Gọi luồng In-App Review của Google Play |
| openStoreListing | Mở trang App Store, bắt buộc truyền mã ứng dụng | Mở trang Google Play theo tên gói |
Lưu ý riêng cho Flutter: mã ứng dụng trên App Store là bắt buộc khi mở trang chợ, và đó là chuỗi số nằm trong địa chỉ trang ứng dụng chứ không phải định danh gói. Nhầm hai giá trị này là lỗi phổ biến nhất khi tích hợp.
11. React Native và Expo
import * as StoreReview from 'expo-store-review';
export async function askForReview() {
if (await StoreReview.isAvailableAsync()) {
await StoreReview.requestReview();
} else {
const url = StoreReview.storeUrl();
if (url) Linking.openURL(url);
}
}
Nhánh dự phòng ở đây có ý nghĩa thực tế: trên các thiết bị Android không có dịch vụ của Google, hàm kiểm tra sẽ trả về giá trị phủ định và bạn vẫn cần một đường thoát để người dùng đánh giá được.
12. Test đúng trên từng môi trường
| Môi trường | Hộp thoại có hiện không | Đánh giá có được gửi không | Dùng để kiểm tra điều gì |
|---|---|---|---|
| Máy giả lập | Có | Không | Vị trí và thời điểm gọi trong luồng giao diện |
| Thiết bị thật, bản gỡ lỗi từ Xcode | Có, không bị giới hạn tần suất | Không | Logic điều kiện, độ trễ, tương tác với các lớp phủ khác |
| Bản TestFlight | Không bao giờ | Không | Không dùng để kiểm tra tính năng này |
| Bản phát hành trên App Store | Có, chịu giới hạn ba lần mỗi năm | Có | Hành vi thật |
Kết luận quan trọng từ bảng này: đừng bao giờ báo lỗi vì popup không hiện trên bản TestFlight. Đó là hành vi thiết kế của Apple, không phải lỗi tích hợp. Hãy ghi rõ điều này vào tài liệu bàn giao cho đội kiểm thử, nếu không bạn sẽ nhận báo cáo lỗi giả ở mọi đợt phát hành.
13. Đo hiệu quả sau khi tích hợp
Ba chỉ số nên theo dõi trong ba tháng đầu:
- Số lượt đánh giá mới mỗi tuần so với giai đoạn trước khi tích hợp. Đây là chỉ số phản ứng nhanh nhất.
- Điểm trung bình của các đánh giá mới tách riêng khỏi điểm trung bình tích lũy. Nếu điểm mới thấp hơn điểm cũ, vấn đề nằm ở thời điểm gọi chứ không nằm ở chất lượng sản phẩm.
- Tỉ lệ đánh giá trên số người dùng hoạt động, để so sánh công bằng giữa các tháng có lượng tải khác nhau.
Điểm đánh giá là một trong những yếu tố ảnh hưởng trực tiếp tới tỉ lệ chuyển đổi từ lượt xem trang sang lượt tải, nên đây là hạng mục thuộc phạm vi tối ưu hiển thị trên chợ ứng dụng, xem bài ASO App Store Optimization.
14. Khi nào KHÔNG nên gọi In-App Review
- Trong ba ngày đầu sau khi cài đặt. Người dùng chưa đủ trải nghiệm để có ý kiến, và bạn vừa đốt một trong ba cơ hội của năm.
- Ngay sau khi ứng dụng vừa gặp sự cố hoặc vừa báo lỗi.
- Trong luồng thanh toán. Bất kỳ thứ gì che màn hình lúc người dùng đang chuẩn bị trả tiền đều làm giảm tỉ lệ hoàn tất, xem thêm bài thiết kế màn hình Paywall.
- Ngay sau khi vừa phát hành một bản cập nhật lớn thay đổi giao diện. Hãy chờ người dùng làm quen, thường là hai tuần.
- Khi ứng dụng đang có lỗi nghiêm trọng chưa vá. Trong giai đoạn này, mỗi lời mời đánh giá là một lời mời cho điểm một sao.
- Với nhóm người dùng vừa gửi phản hồi tiêu cực qua kênh nội bộ. Hãy loại họ khỏi luồng cho tới khi vấn đề của họ được xử lý xong.
Kết luận
Tích hợp popup xin đánh giá trên iOS chỉ mất chừng mười lăm phút viết mã, nhưng phần quyết định kết quả nằm ở ba chỗ: chọn đúng lời gọi cho phiên bản hệ điều hành, hiểu rằng bạn chỉ có ba cơ hội mỗi năm cho mỗi người dùng, và tuyệt đối không dùng popup tự chế để lọc đánh giá xấu.
Uwmee Software nhận phát triển ứng dụng iOS và Android trọn gói, tích hợp sẵn luồng xin đánh giá đúng chuẩn của cả hai chợ ứng dụng. Đọc thêm bài In-App Review API trên Android và bài chiến lược tăng điểm đánh giá tự nhiên.