RN for OpenHarmony英雄联盟助手App实战:技能详情实现
本文介绍了英雄联盟手游技能详情页的实现,重点讲解了数据加载、状态管理和UI布局。通过React Native构建的页面包含被动技能和4个主动技能卡片,每张卡片展示技能图标、名称、类型和清理后的HTML描述文本。文章强调了防御性编程的重要性,如双重条件判断处理加载状态,并分享了ScrollView配置、底部留白等设计技巧。同时解释了为何选择重新请求数据而非路由传参的架构决策,以及异步数据加载的正确写
案例开源地址:https://atomgit.com/nutpi/rn_openharmony_lol
玩英雄联盟,最重要的就是了解技能。冷却多少秒?消耗多少蓝?射程有多远?这些数据直接影响你的对线和团战决策。技能详情页就是要把这些关键信息清晰地呈现出来。
这篇文章我们来实现技能详情页,重点聊聊如何处理 HTML 格式的技能描述、技能图标的加载策略、以及卡片式布局的设计思路。
从英雄详情页跳转过来
用户在英雄详情页点击"技能详情"入口,会带着 championId 参数跳转到这个页面。我们需要根据这个 ID 重新获取英雄数据,然后提取技能信息进行展示。
import React, {useEffect, useState} from 'react';
import {View, Text, ScrollView, Image, StyleSheet} from 'react-native';
import {colors} from '../../styles/colors';
import {useApp} from '../../context/AppContext';
import {useNavigation} from '../../context/NavigationContext';
import {championApi} from '../../api';
import {getSpellIconUrl, getPassiveIconUrl} from '../../utils/image';
import {stripHtml} from '../../utils/format';
import {Loading} from '../../components/common';
import type {ChampionDetail} from '../../models/Champion';
导入模块的组织方式:
我习惯把导入语句分成几组:
- 第一组:React 相关
- 第二组:React Native 组件
- 第三组:项目内的工具和上下文
- 第四组:类型定义
这样分组后,找某个导入会更快。当然这不是强制的,保持团队内一致就好。
为什么要单独导入
stripHtml?拳头的 API 返回的技能描述是带 HTML 标签的,比如:
<mainText>盖伦挥舞大剑,对周围敌人造成<physicalDamage>物理伤害</physicalDamage></mainText>这些标签在网页上可以渲染成带颜色的文字,但在 React Native 的 Text 组件里会原样显示。所以我们需要一个工具函数来清理这些标签。
状态管理与数据加载
export function ChampionSkillPage() {
const {state} = useApp();
const {params} = useNavigation();
const [champion, setChampion] = useState<ChampionDetail | null>(null);
const [loading, setLoading] = useState(true);
const championId = params.championId as string;
状态设计的考量:
你可能会问:为什么不直接从上一个页面传递技能数据过来,而是重新请求?
这是一个架构选择。传递数据的方式确实能省一次网络请求,但有几个问题:
- 数据量大:技能详情数据不小,通过路由参数传递不太优雅
- 页面独立性:如果以后要支持从其他入口(比如搜索结果)直接跳转到技能页,就必须能独立加载数据
- 数据一致性:重新请求能确保拿到最新数据,虽然游戏数据更新不频繁,但这是个好习惯
useEffect(() => {
async function loadDetail() {
if (!state.version || !championId) return;
setLoading(true);
const detail = await championApi.getChampionDetail(
state.version,
championId,
);
setChampion(detail);
setLoading(false);
}
loadDetail();
}, [state.version, championId]);
异步函数的写法:
注意这里在
useEffect内部定义了一个async函数然后立即调用。为什么不直接把useEffect的回调写成 async?// ❌ 错误写法 useEffect(async () => { const detail = await championApi.getChampionDetail(...); }, []); // ✅ 正确写法 useEffect(() => { async function loadDetail() { const detail = await championApi.getChampionDetail(...); } loadDetail(); }, []);因为
useEffect的回调函数如果返回值,必须是一个清理函数。而async函数会返回 Promise,这会导致 React 报警告。
加载状态处理
if (loading || !champion) {
return <Loading fullScreen />;
}
双重条件判断的必要性:
loading || !champion这个条件覆盖了两种情况:
loading为 true:正在请求数据,显示加载动画!champion:请求完成但数据为空(可能是网络错误或英雄不存在)第二种情况虽然少见,但不处理的话,后续代码访问
champion.passive会报错。防御性编程的习惯能避免很多线上问题。
Loading 组件的 fullScreen 属性:
我们的 Loading 组件支持两种模式:
- 默认模式:只显示一个加载指示器,适合局部加载
fullScreen模式:占满整个屏幕,适合页面级加载技能详情页在数据加载完成前没有任何内容可展示,所以用全屏加载更合适。
技能键位映射
const skillKeys = ['Q', 'W', 'E', 'R'];
为什么用数组而不是对象?
API 返回的
spells是一个数组,顺序固定是 Q、W、E、R。用数组映射最直接:skillKeys[0] // 'Q' skillKeys[1] // 'W' skillKeys[2] // 'E' skillKeys[3] // 'R'如果 API 返回的是对象(比如
{q: {...}, w: {...}}),那用对象映射会更合适。数据结构决定代码结构。
页面整体结构
return (
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
{/* 被动技能 */}
<View style={styles.skillCard}>
{/* ... */}
</View>
{/* 主动技能 */}
{champion.spells.map((spell, index) => (
<View key={spell.id} style={styles.skillCard}>
{/* ... */}
</View>
))}
<View style={styles.bottomSpace} />
</ScrollView>
);
ScrollView 的配置:
showsVerticalScrollIndicator={false}隐藏了右侧的滚动条。这是一个设计选择——有些 App 喜欢显示滚动条让用户知道还有更多内容,有些则追求更简洁的视觉效果。在这个页面,技能数量固定(1个被动 + 4个主动),用户很容易就能滚动到底部,所以隐藏滚动条问题不大。
bottomSpace 的作用:
在列表底部加一个空白区域是常见做法,确保最后一个卡片不会紧贴屏幕底部,视觉上更舒适。20px 是一个经验值,你可以根据实际效果调整。
被动技能卡片
被动技能和主动技能的展示略有不同,我们先来看被动技能:
<View style={styles.skillCard}>
<View style={styles.skillHeader}>
<View style={styles.iconWrapper}>
<Image
source={{
uri: getPassiveIconUrl(state.version, champion.passive.image.full),
}}
style={styles.skillIcon}
/>
<View style={[styles.keyBadge, styles.passiveBadge]}>
<Text style={styles.keyText}>P</Text>
</View>
</View>
<View style={styles.skillInfo}>
<Text style={styles.skillName}>{champion.passive.name}</Text>
<Text style={styles.skillType}>被动技能</Text>
</View>
</View>
<Text style={styles.skillDesc}>
{stripHtml(champion.passive.description)}
</Text>
</View>
图标 URL 的生成:
getPassiveIconUrl函数拼接出被动技能图标的完整 URL:export function getPassiveIconUrl(version: string, imageName: string): string { return `${BASE_URL}/cdn/${version}/img/passive/${imageName}`; }被动技能的图标路径是
/img/passive/,和主动技能的/img/spell/不同,所以需要单独的函数。
键位徽章的样式合并:
style={[styles.keyBadge, styles.passiveBadge]}被动技能的徽章用蓝色(info 色),和主动技能的金色区分开。通过样式数组合并,
passiveBadge会覆盖keyBadge中的backgroundColor:keyBadge: { backgroundColor: colors.primary, // 金色 // ... }, passiveBadge: { backgroundColor: colors.info, // 蓝色,覆盖上面的金色 },
skillType 标签的意义:
显示"被动技能"这个标签看起来有点多余(毕竟已经用 P 标识了),但它有两个作用:
- 对新手玩家更友好,不是所有人都知道 P 代表被动
- 和主动技能的元数据(冷却、消耗、范围)位置对应,保持视觉一致性
主动技能卡片
主动技能比被动技能多了冷却、消耗、范围等元数据:
{champion.spells.map((spell, index) => (
<View key={spell.id} style={styles.skillCard}>
<View style={styles.skillHeader}>
<View style={styles.iconWrapper}>
<Image
source={{uri: getSpellIconUrl(state.version, spell.image.full)}}
style={styles.skillIcon}
/>
<View style={styles.keyBadge}>
<Text style={styles.keyText}>{skillKeys[index]}</Text>
</View>
</View>
<View style={styles.skillInfo}>
<Text style={styles.skillName}>{spell.name}</Text>
<View style={styles.skillMeta}>
<Text style={styles.metaText}>
冷却: {spell.cooldownBurn}秒
</Text>
{spell.costBurn !== '0' && (
<Text style={styles.metaText}>
消耗: {spell.costBurn} {spell.costType || ''}
</Text>
)}
<Text style={styles.metaText}>
范围: {spell.rangeBurn}
</Text>
</View>
</View>
</View>
<Text style={styles.skillDesc}>{stripHtml(spell.description)}</Text>
</View>
))}
key 属性的选择:
key={spell.id}使用技能的唯一 ID 作为 key。为什么不用 index?在这个场景下用 index 其实也可以,因为技能列表不会动态增删。但用唯一 ID 是更好的习惯:
- 如果以后加了排序功能,用 index 会导致渲染问题
- 代码意图更清晰,一眼就知道这是用 ID 做标识
条件渲染消耗信息:
{spell.costBurn !== '0' && ( <Text style={styles.metaText}> 消耗: {spell.costBurn} {spell.costType || ''} </Text> )}这里用了短路求值:只有当
costBurn不等于 ‘0’ 时才渲染消耗信息。为什么是字符串 ‘0’ 而不是数字 0?因为 API 返回的
costBurn是字符串类型,可能是 “50/55/60/65/70” 这样的格式(表示不同等级的消耗)。
costType 的处理:
{spell.costType || ''}处理资源类型的显示。大部分英雄的 costType 是"法力",但有些英雄比较特殊:
- 盖伦:没有资源消耗,costBurn 是 ‘0’
- 蛮王:消耗怒气
- 阿卡丽:消耗能量
如果 costType 为空,就不显示单位,避免出现 "消耗: 50 " 这样尾部有空格的情况。
HTML 清理函数详解
技能描述是这个页面最重要的内容,但 API 返回的是带 HTML 标签的文本。我们来看看 stripHtml 函数是怎么处理的:
export function stripHtml(html: string): string {
if (!html) return '';
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.trim();
}
逐行解析:
if (!html) return '';
- 防御性检查,如果传入 null、undefined 或空字符串,直接返回空字符串
.replace(/<br\s*\/?>/gi, '\n')
- 把
<br>或<br/>或<br />替换成换行符\s*匹配零个或多个空白字符\/?匹配零个或一个斜杠gi表示全局匹配且不区分大小写
.replace(/<[^>]+>/g, '')
- 移除所有其他 HTML 标签
<[^>]+>匹配<开头、>结尾、中间是非>字符的内容
.replace(/ /g, ' ')等
- 把 HTML 实体转换成对应的字符
→ 空格&→ &<→ <>→ >
为什么要单独处理
<br>?技能描述中的换行是有意义的,比如:
第一段效果描述 第二段效果描述如果直接把
<br>也删掉,两段文字就会连在一起,可读性变差。所以先把<br>转成\n,保留换行语义。
样式设计详解
容器样式
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
padding: 16,
},
padding 的选择:
16px 是移动端常用的边距值,既不会让内容太挤,也不会浪费太多空间。如果你的设计稿用的是其他值(比如 12px 或 20px),保持全局一致就好。
技能卡片样式
skillCard: {
backgroundColor: colors.backgroundCard,
borderRadius: 12,
padding: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: colors.border,
},
卡片设计的要素:
- 背景色:用
backgroundCard和页面背景区分,形成层次感- 圆角:12px 的圆角让卡片看起来更柔和
- 内边距:16px 让内容不会贴着边缘
- 外边距:卡片之间留 16px 间隙
- 边框:1px 的边框增加卡片的边界感,在深色主题下尤其重要
技能头部布局
skillHeader: {
flexDirection: 'row',
marginBottom: 12,
},
iconWrapper: {
position: 'relative',
},
skillIcon: {
width: 64,
height: 64,
borderRadius: 8,
borderWidth: 2,
borderColor: colors.borderGold,
},
图标尺寸的考量:
64x64 是一个平衡点:
- 太小(如 48x48):图标细节看不清,特别是一些复杂的技能图标
- 太大(如 80x80):占用太多空间,一屏能显示的技能数量减少
金色边框(
borderGold)是英雄联盟的视觉特征,让图标更有游戏感。
键位徽章样式
keyBadge: {
position: 'absolute',
bottom: -6,
right: -6,
backgroundColor: colors.primary,
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
passiveBadge: {
backgroundColor: colors.info,
},
keyText: {
fontSize: 14,
fontWeight: 'bold',
color: colors.background,
},
绝对定位的技巧:
徽章使用绝对定位,相对于
iconWrapper(设置了position: 'relative')定位。
bottom: -6, right: -6让徽章稍微超出图标边界,形成"贴纸"效果。如果用bottom: 0, right: 0,徽章会完全在图标内部,视觉上不够突出。
圆形的实现:
width: 24, height: 24, borderRadius: 12——宽高相等,圆角是宽高的一半,就得到一个正圆。这是 CSS/React Native 中画圆的标准方法。
技能信息区域
skillInfo: {
flex: 1,
marginLeft: 16,
justifyContent: 'center',
},
skillName: {
fontSize: 18,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: 4,
},
skillType: {
fontSize: 14,
color: colors.info,
},
skillMeta: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 4,
},
metaText: {
fontSize: 12,
color: colors.textSecondary,
marginRight: 12,
},
flex: 1 的作用:
skillInfo设置flex: 1会占据除图标外的所有剩余空间。这样无论技能名称多长,布局都不会乱。
flexWrap: ‘wrap’ 的必要性:
元数据(冷却、消耗、范围)在一行显示。如果屏幕较窄或者数值较长,
flexWrap: 'wrap'允许内容换行,避免被截断。
技能描述样式
skillDesc: {
fontSize: 14,
color: colors.textSecondary,
lineHeight: 22,
},
lineHeight 的设置:
lineHeight: 22配合fontSize: 14,行高是字号的 1.57 倍。这个比例让多行文本阅读起来更舒适,不会太挤也不会太松。一般来说,正文的行高在 1.4-1.8 倍之间都是合理的。
图片加载优化
技能图标虽然不大,但一个页面要加载 5 张(1 被动 + 4 主动)。我们来看看 Image 组件的一些优化技巧:
<Image
source={{uri: getSpellIconUrl(state.version, spell.image.full)}}
style={styles.skillIcon}
/>
React Native Image 的缓存机制:
React Native 的 Image 组件默认会缓存网络图片。同一个 URL 的图片只会下载一次,后续使用会从缓存读取。
这意味着:
- 用户第二次进入同一个英雄的技能页,图片会秒加载
- 不同英雄的相同技能图标(如果有的话)也会复用缓存
图片加载失败的处理:
当前代码没有处理图片加载失败的情况。在生产环境中,你可能需要:
<Image source={{uri: iconUrl}} style={styles.skillIcon} defaultSource={require('./placeholder.png')} // iOS onError={() => setImageError(true)} // 自定义错误处理 />不过拳头的 CDN 非常稳定,图片加载失败的概率很低,所以这个项目暂时没加这个处理。
完整代码回顾
把上面的片段组合起来,就是完整的技能详情页。整个页面的数据流是这样的:
- 从路由参数获取
championId - 调用 API 获取英雄详情数据
- 从详情数据中提取
passive(被动)和spells(主动技能) - 遍历渲染每个技能卡片
- 用
stripHtml清理技能描述中的 HTML 标签
代码量不多,但涉及的知识点不少:异步数据加载、条件渲染、样式组织、正则表达式处理文本……这些都是 React Native 开发中的常见场景。
可能的扩展方向
当前的技能详情页已经能满足基本需求,但还有一些可以优化的地方:
1. 技能等级切换
API 返回的数据其实包含了技能在不同等级下的数值,比如 cooldownBurn: "10/9/8/7/6" 表示 1-5 级的冷却时间。可以加一个等级选择器,让用户查看特定等级的数据。
2. 技能视频演示
拳头官网有技能的演示视频,可以在技能卡片上加一个播放按钮,点击后播放视频。不过这需要额外的视频播放组件支持。
3. 伤害计算
结合英雄的基础属性和技能的伤害公式,可以计算出不同装备下的技能伤害。这个功能比较复杂,我们会在后面的"伤害计算"工具页实现。
小结
技能详情页的核心挑战是处理 API 返回的复杂数据结构和 HTML 格式的文本。我们通过以下方式解决:
- 类型定义:用 TypeScript 接口描述技能数据的结构,让代码更可靠
- 文本清理:用正则表达式移除 HTML 标签,保留换行语义
- 条件渲染:根据数据内容决定是否显示某些元素(如消耗信息)
- 卡片布局:用 Flexbox 实现图标 + 信息的横向排列
下一篇我们来实现皮肤列表页,展示英雄的所有皮肤,并支持点击查看大图。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐




所有评论(0)