在这里插入图片描述

案例开源地址: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;

状态设计的考量:

你可能会问:为什么不直接从上一个页面传递技能数据过来,而是重新请求?

这是一个架构选择。传递数据的方式确实能省一次网络请求,但有几个问题:

  1. 数据量大:技能详情数据不小,通过路由参数传递不太优雅
  2. 页面独立性:如果以后要支持从其他入口(比如搜索结果)直接跳转到技能页,就必须能独立加载数据
  3. 数据一致性:重新请求能确保拿到最新数据,虽然游戏数据更新不频繁,但这是个好习惯
  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 这个条件覆盖了两种情况:

  1. loading 为 true:正在请求数据,显示加载动画
  2. !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 标识了),但它有两个作用:

  1. 对新手玩家更友好,不是所有人都知道 P 代表被动
  2. 和主动技能的元数据(冷却、消耗、范围)位置对应,保持视觉一致性

主动技能卡片

主动技能比被动技能多了冷却、消耗、范围等元数据:

{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(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .trim();
}

逐行解析:

  1. if (!html) return '';

    • 防御性检查,如果传入 null、undefined 或空字符串,直接返回空字符串
  2. .replace(/<br\s*\/?>/gi, '\n')

    • <br><br/><br /> 替换成换行符
    • \s* 匹配零个或多个空白字符
    • \/? 匹配零个或一个斜杠
    • gi 表示全局匹配且不区分大小写
  3. .replace(/<[^>]+>/g, '')

    • 移除所有其他 HTML 标签
    • <[^>]+> 匹配 < 开头、> 结尾、中间是非 > 字符的内容
  4. .replace(/&nbsp;/g, ' ')

    • 把 HTML 实体转换成对应的字符
    • &nbsp; → 空格
    • &amp; → &
    • &lt; → <
    • &gt; → >

为什么要单独处理 <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 非常稳定,图片加载失败的概率很低,所以这个项目暂时没加这个处理。


完整代码回顾

把上面的片段组合起来,就是完整的技能详情页。整个页面的数据流是这样的:

  1. 从路由参数获取 championId
  2. 调用 API 获取英雄详情数据
  3. 从详情数据中提取 passive(被动)和 spells(主动技能)
  4. 遍历渲染每个技能卡片
  5. stripHtml 清理技能描述中的 HTML 标签

代码量不多,但涉及的知识点不少:异步数据加载、条件渲染、样式组织、正则表达式处理文本……这些都是 React Native 开发中的常见场景。


可能的扩展方向

当前的技能详情页已经能满足基本需求,但还有一些可以优化的地方:

1. 技能等级切换

API 返回的数据其实包含了技能在不同等级下的数值,比如 cooldownBurn: "10/9/8/7/6" 表示 1-5 级的冷却时间。可以加一个等级选择器,让用户查看特定等级的数据。

2. 技能视频演示

拳头官网有技能的演示视频,可以在技能卡片上加一个播放按钮,点击后播放视频。不过这需要额外的视频播放组件支持。

3. 伤害计算

结合英雄的基础属性和技能的伤害公式,可以计算出不同装备下的技能伤害。这个功能比较复杂,我们会在后面的"伤害计算"工具页实现。


小结

技能详情页的核心挑战是处理 API 返回的复杂数据结构和 HTML 格式的文本。我们通过以下方式解决:

  1. 类型定义:用 TypeScript 接口描述技能数据的结构,让代码更可靠
  2. 文本清理:用正则表达式移除 HTML 标签,保留换行语义
  3. 条件渲染:根据数据内容决定是否显示某些元素(如消耗信息)
  4. 卡片布局:用 Flexbox 实现图标 + 信息的横向排列

下一篇我们来实现皮肤列表页,展示英雄的所有皮肤,并支持点击查看大图。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐