Giới thiệu
Bài viết này tập trung vào việc triển khai giao diện hiển thị các trò chơi nổi bật trong một ứng dụng tin tức Steam được xây dựng bằng React Native trên nền tảng OpenHarmony. Mặc dù trang này có vẻ đơn giản, nhưng nó bao gồm nhiều chi tiết quan trọng liên quan đến xử lý dữ liệu từ API, tối ưu hóa việc hiển thị danh sách và cách tái sử dụng các thành phần giao diện.
Phân tích yêu cầu
Mục tiêu chính của màn hình trò chơi nổi bật là trình bày danh sách các tựa game được Steam chính thức đề xuất. Người dùng sẽ truy cập trang này để xem các trò chơi được chọn lọc, và có thể nhấp vào bất kỳ thẻ game nào để chuyển đến trang chi tiết của trò chơi đó.
Về mặt trải nghiệm người dùng, màn hình này cần có các khả năng sau:
- Tự động tải dữ liệu khi khởi tạo.
- Hiển thị trạng thái tải (loading) trong quá trình chờ dữ liệu.
- Hiển thị danh sách game sau khi dữ liệu được tải thành công.
- Hỗ trợ chức năng quay lại màn hình trước đó.
Mô tả API dữ liệu
Dữ liệu cho các game nổi bật được lấy từ endpoint /api/featured của Steam. API này cho phép truy vấn GET trực tiếp mà không yêu cầu xác thực, giúp việc tích hợp trở nên thuận tiện. Dưới đây là cách chúng ta đã đóng gói lệnh gọi API trong src/api/steamService.ts:
const BASE_API_URL = 'https://store.steampowered.com/api';
export const fetchHighlightedGames = async () => {
try {
const response = await fetch(`${BASE_API_URL}/featured`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Failed to fetch highlighted games:", error);
throw error; // Re-throw to be handled by the component
}
};
Dữ liệu trả về (phiên bản rút gọn) có cấu trúc như sau:
{
"highlighted_windows": [
{
"gameId": 570,
"title": "Dota 2",
"hasDiscount": false,
"discountPercentage": 0,
"initialPriceCents": 0,
"currentPriceCents": 0,
"headerImageUrl": "https://cdn.steamstatic.com/steam/apps/570/header.jpg"
},
{
"gameId": 730,
"title": "Counter-Strike 2",
"hasDiscount": true,
"discountPercentage": 25,
"initialPriceCents": 100000,
"currentPriceCents": 75000,
"headerImageUrl": "https://cdn.steamstatic.com/steam/apps/730/header.jpg"
}
],
"highlighted_mac": [...],
"specials_offers": {...}
}
Chúng ta chủ yếu sử dụng trường highlighted_windows, chứa danh sách các trò chơi nổi bật trên nền tảng Windows. Mỗi đối tượng trò chơi bao gồm ID, tên, thông tin giảm giá, giá cả và URL hình ảnh bìa.
Lưu ý: Các trường giá như initialPriceCents và currentPriceCents được tính bằng đơn vị xu (cents), do đó cần chia cho 100 để chuyển đổi sang đơn vị tiền tệ chính (ví dụ: VNĐ hoặc USD) khi hiển thị.
Thành phần hiển thị trạng thái tải (Loading Component)
Trong quá trình tải dữ liệu, cần cung cấp phản hồi trực quan cho người dùng, thông báo rằng "dữ liệu đang được xử lý". Chúng ta sử dụng một thành phần DataLoadingIndicator đơn giản cho mục đích này:
import React from 'react';
import {View, ActivityIndicator, Text, StyleSheet} from 'react-native';
interface ProgressIndicatorProps {
displayText?: string;
}
export const DataLoadingIndicator = ({ displayText = 'Đang tải dữ liệu...' }: ProgressIndicatorProps) => (
<View style={componentStyles.centerContainer}>
<ActivityIndicator size="large" color="#4a90e2" />
<Text style={componentStyles.messageText}>{displayText}</Text>
</View>
);
const componentStyles = StyleSheet.create({
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#1e242d', // Nền màu tối
},
messageText: {
fontSize: 15,
color: '#b0b8c2', // Màu chữ sáng
marginTop: 15,
},
});
Thành phần này nhận một tham số tùy chọn displayText, mặc định là "Đang tải dữ liệu...". ActivityIndicator là một chỉ báo tải tích hợp trong React Native, được đặt màu xanh đậm `#4a90e2` để phù hợp với chủ đề.
Tổng quan về thành phần thẻ Game (GameCard Component)
Trọng tâm của trang game nổi bật là danh sách các thẻ game, mỗi thẻ được render bằng thành phần GameDisplayCard. Thành phần này có thể đã được triển khai trước đó, chúng ta sẽ xem xét lại các phần quan trọng của nó.
Định nghĩa Props
interface GameDisplayCardProps {
applicationId: number;
gameTitle: string;
imageUrl?: string;
currentDisplayPrice?: string;
discountPercentage?: number;
initialDisplayPrice?: string;
}
Một số điểm cần lưu ý khi thiết kế interface này:
applicationIdlà bắt buộc, vì khi nhấp vào thẻ cần biết ID trò chơi để chuyển đến trang chi tiết.imageUrllà tùy chọn; nếu không được cung cấp, hình ảnh sẽ tự động được tạo từapplicationIdbằng cách ghép với địa chỉ CDN của Steam.discountPercentagevàinitialDisplayPricecũng là tùy chọn, chỉ cần truyền khi trò chơi có giảm giá.
Logic tải hình ảnh
<Image
source={{ uri: imageUrl || `https://cdn.steamstatic.com/steam/apps/${applicationId}/header.jpg` }}
style={imageStyles.thumbnail}
resizeMode="cover"
/>
Ở đây có một chiến lược dự phòng: nếu người gọi truyền imageUrl, nó sẽ được sử dụng; ngược lại, URL hình ảnh bìa mặc định sẽ được tạo dựa trên applicationId. Các địa chỉ CDN của Steam có định dạng cố định, cho phép truy xuất hình ảnh bìa chỉ với appId.
Xử lý sự kiện nhấp chuột
// Giả định context cho điều hướng và quản lý trạng thái tồn tại
const { goToDetailsScreen, updateCurrentGameId, addGameToViewHistory } = useAppNavigationContext();
const handleCardPress = () => {
updateCurrentGameId(applicationId);
addGameToViewHistory(applicationId);
goToDetailsScreen('GameDetails'); // Tên màn hình có thể thay đổi tùy cấu hình
};
Khi nhấp vào thẻ, ba hành động chính sẽ được thực hiện:
- Lưu
applicationIdcủa trò chơi hiện tại vào trạng thái toàn cục. - Thêm trò chơi vào lịch sử xem (hữu ích cho tính năng "đã xem gần đây").
- Chuyển đến màn hình chi tiết trò chơi.
Render khu vực giá
<View style={priceContainerStyles.priceLayout}>
{discountPercentage && discountPercentage > 0 ? (
<>
<View style={priceContainerStyles.discountLabel}>
<Text style={priceContainerStyles.discountValue}>-{discountPercentage}%</Text>
</View>
<Text style={priceContainerStyles.oldPrice}>{initialDisplayPrice}</Text>
<Text style={priceContainerStyles.currentPrice}>{currentDisplayPrice}</Text>
</>
) : (
<Text style={priceContainerStyles.currentPrice}>{currentDisplayPrice || 'Miễn phí'}</Text>
)}
</View>
Logic này xử lý ba trường hợp:
- Có giảm giá: Hiển thị nhãn giảm giá, giá gốc gạch ngang và giá hiện tại.
- Không giảm giá nhưng có giá: Chỉ hiển thị giá.
- Không có giá: Hiển thị "Miễn phí".
Nhãn giảm giá thường có nền màu xanh đậm và chữ màu xanh lá cây sáng, giống như cách Steam hiển thị.
Triển khai màn hình game nổi bật
Sau khi chuẩn bị các thành phần cơ bản, chúng ta sẽ xem xét việc triển khai hoàn chỉnh màn hình game nổi bật.
Khai báo import
import React, { useEffect, useState } from 'react';
import { View, ScrollView, StyleSheet } from 'react-native';
import { AppHeader } from '../components/AppHeader';
import { PrimaryNavBar } from '../components/PrimaryNavBar';
import { GameDisplayCard } from '../components/GameCard';
import { DataLoadingIndicator } from '../components/Loading';
import { fetchHighlightedGames } from '../api/steamService';
Các module quan trọng được nhập bao gồm:
AppHeadervàPrimaryNavBarcho phần đầu và điều hướng dưới cùng của trang.GameDisplayCardđể hiển thị thẻ trò chơi.DataLoadingIndicatorđể hiển thị trạng thái tải.fetchHighlightedGameslà phương thức API để lấy dữ liệu game nổi bật.
Định nghĩa trạng thái
export const HighlightsPage = () => {
const [highlightedGames, setHighlightedGames] = useState([]);
const [isLoadingData, setIsLoadingData] = useState(true);
Chúng ta chỉ cần hai trạng thái:
highlightedGames: Lưu trữ danh sách dữ liệu trò chơi, khởi tạo là một mảng rỗng.isLoadingData: Kiểm soát trạng thái tải, khởi tạo làtrue.
Lưu ý: Kiểu dữ liệu cho highlightedGames đang sử dụng any[]. Trong các dự án thực tế, nên định nghĩa một interface kiểu cụ thể để TypeScript có thể cung cấp kiểm tra kiểu và gợi ý mã tốt hơn.
Tải dữ liệu
useEffect(() => {
const loadGameData = async () => {
try {
const apiResponse = await fetchHighlightedGames();
setHighlightedGames(apiResponse?.highlighted_windows || []);
} catch (error) {
console.error("Error fetching highlights:", error);
// Có thể thiết lập trạng thái lỗi tại đây để hiển thị thông báo cho người dùng
} finally {
setIsLoadingData(false);
}
};
loadGameData();
}, []);
Sử dụng useEffect để kích hoạt yêu cầu dữ liệu khi thành phần được gắn kết. Có một vài chi tiết quan trọng ở đây:
- Toán tử Optional Chaining
apiResponse?.highlighted_windows: Ngăn chặn lỗi khiapiResponsecó thể là null hoặc undefined. - Giá trị mặc định mảng rỗng
|| []: Nếuhighlighted_windowskhông tồn tại, sử dụng mảng rỗng để tránh lỗi khi map sau này. - Xử lý trong
finally: Đảm bảoisLoadingDatađược đặt thànhfalsesau khi API hoàn tất, bất kể thành công hay thất bại.
Cách viết này khá gọn gàng. Nếu muốn xử lý lỗi hoàn chỉnh hơn (ví dụ: hiển thị thông báo lỗi, hỗ trợ thử lại), có thể thêm một trạng thái error.
Render giao diện
return (
<View style={pageStyles.mainWrapper}>
<AppHeader screenTitle="Trò chơi Nổi bật" showBackButton />
{isLoadingData ? (
<DataLoadingIndicator />
) : (
<ScrollView style={pageStyles.contentArea}>
{highlightedGames.map((gameItem: any) => (
<GameDisplayCard
key={gameItem.gameId}
applicationId={gameItem.gameId}
gameTitle={gameItem.title}
imageUrl={gameItem.headerImageUrl}
currentDisplayPrice={gameItem.currentPriceCents ? `₫${(gameItem.currentPriceCents / 100).toFixed(0)}` : 'Miễn phí'}
discountPercentage={gameItem.discountPercentage}
initialDisplayPrice={gameItem.initialPriceCents ? `₫${(gameItem.initialPriceCents / 100).toFixed(0)}` : ''}
/>
))}
</ScrollView>
)}
<PrimaryNavBar />
</View>
);
};
Cấu trúc trang bao gồm ba phần:
- AppHeader: Hiển thị tiêu đề "Trò chơi Nổi bật" và bật nút quay lại (
showBackButton). - Khu vực nội dung: Hiển thị
DataLoadingIndicatorhoặc danh sách trò chơi tùy thuộc vào trạng tháiisLoadingData. - PrimaryNavBar: Thanh điều hướng ở phía dưới.
Danh sách trò chơi được gói trong ScrollView, sử dụng phương thức map để lặp qua mảng highlightedGames và render từng GameDisplayCard.
Xử lý giá cần lưu ý:
currentDisplayPrice={gameItem.currentPriceCents ? `₫${(gameItem.currentPriceCents / 100).toFixed(0)}` : 'Miễn phí'}
initialDisplayPrice={gameItem.initialPriceCents ? `₫${(gameItem.initialPriceCents / 100).toFixed(0)}` : ''}
- Khi
currentPriceCentstồn tại, nó được chia cho 100 để chuyển đổi sang đơn vị tiền tệ (ví dụ: VNĐ), làm tròn đến 0 chữ số thập phân. - Nếu
currentPriceCentskhông tồn tại, hiển thị "Miễn phí". - Sử dụng template literals để ghép ký hiệu tiền tệ.
Định nghĩa Style
const pageStyles = StyleSheet.create({
mainWrapper: { flex: 1, backgroundColor: '#1b2838' },
contentArea: { flex: 1, padding: 12 },
});
Các style rất đơn giản:
mainWrapperchiếm toàn bộ màn hình, với màu nền tối của Steam.contentAreacũng chiếm toàn bộ không gian còn lại, với padding 12 ở các cạnh.
Tại sao sử dụng ScrollView thay vì FlatList?
Có thể có câu hỏi rằng tại sao lại sử dụng ScrollView thay vì FlatList để hiển thị danh sách, khi mà FlatList thường được biết đến với hiệu suất tốt hơn cho các danh sách dài. Thực tế, FlatList có các tối ưu hóa cho danh sách ảo, chỉ render các phần tử trong vùng hiển thị, giúp cải thiện hiệu suất đáng kể cho các danh sách rất dài.
Tuy nhiên, danh sách trò chơi nổi bật thường chỉ có khoảng mười đến hai mươi mục. Đối với số lượng dữ liệu này, ScrollView hoàn toàn đủ dùng và có mã nguồn đơn giản hơn. Nếu sau này lượng dữ liệu danh sách tăng lên (ví dụ: hàng trăm mục), có thể cân nhắc chuyển sang FlatList như ví dụ dưới đây:
<FlatList
data={highlightedGames}
keyExtractor={(item) => item.gameId.toString()}
renderItem={({ item }) => (
<GameDisplayCard
applicationId={item.gameId}
gameTitle={item.title}
imageUrl={item.headerImageUrl}
currentDisplayPrice={item.currentPriceCents ? `₫${(item.currentPriceCents / 100).toFixed(0)}` : 'Miễn phí'}
discountPercentage={item.discountPercentage}
initialDisplayPrice={item.initialPriceCents ? `₫${(item.initialPriceCents / 100).toFixed(0)}` : ''}
/>
)}
/>
Mã nguồn hoàn chỉnh
Dưới đây là mã nguồn hoàn chỉnh cho màn hình trò chơi nổi bật để tham khảo:
import React, { useEffect, useState } from 'react';
import { View, ScrollView, StyleSheet } from 'react-native';
import { AppHeader } from '../components/AppHeader';
import { PrimaryNavBar } from '../components/PrimaryNavBar';
import { GameDisplayCard } from '../components/GameCard';
import { DataLoadingIndicator } from '../components/Loading';
import { fetchHighlightedGames } from '../api/steamService';
export const HighlightsPage = () => {
const [highlightedGames, setHighlightedGames] = useState([]);
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => {
const loadGameData = async () => {
try {
const apiResponse = await fetchHighlightedGames();
setHighlightedGames(apiResponse?.highlighted_windows || []);
} catch (error) {
console.error("Error fetching highlights:", error);
// Có thể thiết lập trạng thái lỗi tại đây để hiển thị thông báo cho người dùng
} finally {
setIsLoadingData(false);
}
};
loadGameData();
}, []);
return (
<View style={pageStyles.mainWrapper}>
<AppHeader screenTitle="Trò chơi Nổi bật" showBackButton />
{isLoadingData ? (
<DataLoadingIndicator />
) : (
<ScrollView style={pageStyles.contentArea}>
{highlightedGames.map((gameItem: any) => (
<GameDisplayCard
key={gameItem.gameId}
applicationId={gameItem.gameId}
gameTitle={gameItem.title}
imageUrl={gameItem.headerImageUrl}
currentDisplayPrice={gameItem.currentPriceCents ? `₫${(gameItem.currentPriceCents / 100).toFixed(0)}` : 'Miễn phí'}
discountPercentage={gameItem.discountPercentage}
initialDisplayPrice={gameItem.initialPriceCents ? `₫${(gameItem.initialPriceCents / 100).toFixed(0)}` : ''}
/>
))}
</ScrollView>
)}
<PrimaryNavBar />
</View>
);
};
const pageStyles = StyleSheet.create({
mainWrapper: { flex: 1, backgroundColor: '#1b2838' },
contentArea: { flex: 1, padding: 12 },
});
Tổng kết
Phần này đã trình bày cách triển khai màn hình trò chơi nổi bật. Dù lượng mã nguồn không quá lớn, nhưng nó đã làm rõ một số khái niệm quan trọng:
- Xử lý dữ liệu API: Hiểu cấu trúc dữ liệu trả về từ API và chuyển đổi đơn vị giá chính xác.
- Tái sử dụng thành phần: Tối ưu hóa quá trình phát triển thông qua việc tái sử dụng các component
DataLoadingIndicatorvàGameDisplayCard. - Quản lý trạng thái: Sử dụng
useStateđể quản lý trạng thái cục bộ vàuseEffectcho các tác dụng phụ. - Lập trình phòng ngừa: Đảm bảo mã nguồn bền vững hơn bằng cách sử dụng Optional Chaining, cung cấp giá trị mặc định cho các trường rỗng, và xử lý lỗi hiệu quả.