HarmonyOS APP开发---"红卡志"卡片收集App,需要用到这个API

0 阅读5分钟

如果你想做一个卡片收集App,需要用到这个API

想要亲自体验"红卡志"这款App的功能?打开你的鸿蒙手机,前往华为应用市场,搜索"红卡志"即可下载安装,把你的卡片收藏都数字化管理起来。


写在前面

卡片收集是一个很有趣的爱好。不管是球星卡、动漫卡、还是各种限定款,收集的过程本身就有一种成就感。但卡片多了之后,想找一张特定的卡反而变得困难 -- 到底放在哪个盒子里了?这张卡是什么时候收到的?价值多少?

"红卡志"就是帮你解决这些问题的App。拍照记录、分类管理、稀有度标记,把你的实体卡牌变成一个数字化的收藏库。这篇文章重点讲两个API:PhotoViewPicker(从相册选择卡片照片)和图片处理(获取图片信息并展示)。

这篇文章聊什么

  1. PhotoViewPicker的使用 -- 怎么让用户从相册里选择卡片的照片
  2. PixelMap的创建和显示 -- 怎么把选择的图片在App里展示出来
  3. 图片信息的获取和展示 -- 怎么获取图片的尺寸、格式等元数据
flowchart TD
    A[打开红卡志] --> B[加载卡片收藏列表]
    B --> C{用户操作}
    C -->|添加卡片| D[PhotoViewPicker.select]
    D --> E[获取photoUris]
    E --> F[创建PixelMap]
    F --> G[获取图片信息]
    G --> H[创建卡片记录]
    H --> I[添加到收藏列表]
    C -->|查看卡片| J[点击卡片Item]
    J --> K[显示大图和详情]
    C -->|设置稀有度| L[选择稀有度等级]
    L --> M[更新卡片数据]
    C -->|删除卡片| N[从列表移除]

第一步:React版 -- 用input选择图片

Web端选择图片用的是<input type="file">,这是最传统的方式。React里通过ref或者onChange来获取选择的文件。

import React, { useState, useRef } from 'react';

// 稀有度等级定义
const RARITY_LEVELS = [
  { key: 'common', label: '普通', color: '#AAAAAA', stars: 1 },
  { key: 'rare', label: '稀有', color: '#4ECDC4', stars: 2 },
  { key: 'epic', label: '史诗', color: '#9B59B6', stars: 3 },
  { key: 'legendary', label: '传说', color: '#F39C12', stars: 4 },
  { key: 'mythic', label: '神话', color: '#E74C3C', stars: 5 },
];

function CardCollectionPage() {
  const [cards, setCards] = useState([]);
  const [selectedCard, setSelectedCard] = useState(null);
  const fileInputRef = useRef(null);

  // 处理文件选择
  const handleFileChange = (e) => {
    const files = e.target.files;
    if (!files || files.length === 0) return;

    Array.from(files).forEach(file => {
      // 用FileReader读取图片
      const reader = new FileReader();
      reader.onload = (event) => {
        // 获取图片的原始尺寸
        const img = new Image();
        img.onload = () => {
          const newCard = {
            id: Date.now() + Math.random(),
            name: '未命名卡片',
            imageUrl: event.target.result, // data URL
            width: img.naturalWidth,
            height: img.naturalHeight,
            fileSize: file.size,
            rarity: 'common',
            date: new Date().toISOString().split('T')[0],
            tags: [],
          };
          setCards(prev => [...prev, newCard]);
        };
        img.src = event.target.result;
      };
      reader.readAsDataURL(file);
    });

    // 重置input,允许重复选择同一文件
    e.target.value = '';
  };

  // 更新稀有度
  const updateRarity = (cardId, rarity) => {
    setCards(prev => prev.map(card =>
      card.id === cardId ? { ...card, rarity } : card
    ));
  };

  // 删除卡片
  const deleteCard = (cardId) => {
    setCards(prev => prev.filter(card => card.id !== cardId));
    if (selectedCard?.id === cardId) setSelectedCard(null);
  };

  // 格式化文件大小
  const formatSize = (bytes) => {
    if (bytes < 1024) return bytes + ' B';
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
    return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
  };

  return (
    <div style={{ padding: 20, maxWidth: 500, margin: '0 auto' }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
        <h1>红卡志</h1>
        <button onClick={() => fileInputRef.current.click()} style={{ padding: '8px 16px', backgroundColor: '#FF6B6B', color: '#fff', border: 'none', borderRadius: 4 }}>
          添加卡片
        </button>
      </div>
      <input
        ref={fileInputRef}
        type="file"
        accept="image/*"
        multiple
        style={{ display: 'none' }}
        onChange={handleFileChange}
      />

      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
        {cards.map(card => {
          const rarity = RARITY_LEVELS.find(r => r.key === card.rarity);
          return (
            <div
              key={card.id}
              onClick={() => setSelectedCard(card)}
              style={{
                border: `2px solid ${rarity?.color || '#ddd'}`,
                borderRadius: 8,
                overflow: 'hidden',
                cursor: 'pointer',
                position: 'relative',
              }}
            >
              <img src={card.imageUrl} alt={card.name} style={{ width: '100%', height: 200, objectFit: 'cover' }} />
              <div style={{ padding: 8 }}>
                <div style={{ fontWeight: 'bold', fontSize: 14 }}>{card.name}</div>
                <div style={{ fontSize: 12, color: '#999' }}>{card.date} | {formatSize(card.fileSize)}</div>
                <div style={{ fontSize: 12, color: rarity?.color }}>{'★'.repeat(rarity?.stars || 0)} {rarity?.label}</div>
              </div>
            </div>
          );
        })}
      </div>

      {selectedCard && (
        <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 100 }}>
          <div style={{ backgroundColor: '#fff', borderRadius: 12, padding: 20, maxWidth: 400, width: '90%' }}>
            <img src={selectedCard.imageUrl} alt={selectedCard.name} style={{ width: '100%', borderRadius: 8 }} />
            <h3>{selectedCard.name}</h3>
            <p>尺寸: {selectedCard.width}x{selectedCard.height}</p>
            <p>大小: {formatSize(selectedCard.fileSize)}</p>
            <div style={{ marginTop: 10 }}>
              <strong>稀有度:</strong>
              <div style={{ display: 'flex', gap: 8, marginTop: 5 }}>
                {RARITY_LEVELS.map(r => (
                  <button
                    key={r.key}
                    onClick={() => updateRarity(selectedCard.id, r.key)}
                    style={{ padding: '4px 8px', border: `1px solid ${r.color}`, borderRadius: 4, backgroundColor: selectedCard.rarity === r.key ? r.color : '#fff', color: selectedCard.rarity === r.key ? '#fff' : r.color }}
                  >
                    {r.label}
                  </button>
                ))}
              </div>
            </div>
            <button onClick={() => { deleteCard(selectedCard.id); }} style={{ marginTop: 10, padding: '8px 16px', backgroundColor: '#FF6B6B', color: '#fff', border: 'none', borderRadius: 4 }}>
              删除
            </button>
            <button onClick={() => setSelectedCard(null)} style={{ marginTop: 10, marginLeft: 8, padding: '8px 16px', border: '1px solid #ccc', borderRadius: 4, backgroundColor: '#fff' }}>
              关闭
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

export default CardCollectionPage;

Web端选择图片用的是<input type="file" accept="image/*" multiple>,通过FileReader把文件读成data URL来显示。简单直接,但data URL的方式对大图片不太友好,会把图片数据全部编码到字符串里。


第二步:ArkTS版 -- PhotoViewPicker + 图片处理

鸿蒙的PhotoViewPicker比Web的file input好用得多。它是系统级的图片选择器,用户可以在一个专门的界面上浏览和选择图片,体验比浏览器原生的文件选择好太多。

import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { image } from '@kit.MediaLibraryKit';
import { promptAction } from '@kit.ArkUI';

// 稀有度配置
interface RarityConfig {
  key: string;
  label: string;
  color: string;
  stars: number;
}

const RARITY_LEVELS: RarityConfig[] = [
  { key: 'common', label: '普通', color: '#AAAAAA', stars: 1 },
  { key: 'rare', label: '稀有', color: '#4ECDC4', stars: 2 },
  { key: 'epic', label: '史诗', color: '#9B59B6', stars: 3 },
  { key: 'legendary', label: '传说', color: '#F39C12', stars: 4 },
  { key: 'mythic', label: '神话', color: '#E74C3C', stars: 5 },
];

// 卡片数据结构
interface CardData {
  id: number;
  name: string;
  imagePath: string;       // 图片URI
  width: number;
  height: number;
  fileSize: number;
  rarity: string;          // 稀有度key
  date: string;            // 收藏日期
  description: string;
}

@Entry
@Component
struct CardCollectionPage {
  @State cards: CardData[] = [];
  @State selectedCard: CardData | null = null;
  @State showDetail: boolean = false;
  // 图片预览的PixelMap
  @State previewPixelMap: PixelMap | null = null;

  aboutToAppear() {
    // 加载一些示例数据
    this.cards = [
      { id: 1, name: '限定星空卡', imagePath: 'cards/star.jpg', width: 800, height: 1200, fileSize: 256000, rarity: 'legendary', date: '2025-12-01', description: '星空系列限定卡' },
      { id: 2, name: '经典红龙卡', imagePath: 'cards/red_dragon.jpg', width: 600, height: 900, fileSize: 128000, rarity: 'epic', date: '2026-01-15', description: '经典龙族系列' },
      { id: 3, name: '初始铜牌卡', imagePath: 'cards/bronze.jpg', width: 500, height: 750, fileSize: 64000, rarity: 'common', date: '2026-02-20', description: '新手包附赠' },
    ];
  }

  // ==================== 从相册选择图片 ====================
  async pickImages(): Promise<string[]> {
    try {
      // 创建PhotoViewPicker实例
      // 这是一个系统级的相册选择器,会弹出系统的相册浏览界面
      const picker = new photoAccessHelper.PhotoViewPicker();

      // 配置选择选项
      const options = new photoAccessHelper.PhotoSelectOptions();
      // MIMEType指定只选择图片
      options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
      // 最多选择9张
      // 为什么是9?因为卡片收集经常需要一次录入多张
      options.maxSelectNumber = 9;

      // 调用select方法,等待用户选择
      // 这个方法会阻塞当前代码执行,直到用户选完或者取消
      const result = await picker.select(options);

      if (result.photoUris && result.photoUris.length > 0) {
        // photoUris是选中图片的URI数组
        // 这些URI可以直接用来创建ImageSource或Image组件的src
        return result.photoUris;
      }
    } catch (err) {
      console.error('Pick images failed: ' + JSON.stringify(err));
    }
    return [];
  }

  // ==================== 获取图片信息 ====================
  // 从图片URI中提取元数据
  async getImageInfo(uri: string): Promise<{ width: number; height: number; size: number }> {
    try {
      const imageSource = image.createImageSource(uri);
      const imageInfo = await imageSource.getImageInfo();

      // getImageInfo返回的信息包括:
      // size: { width, height } -- 图片的像素尺寸
      // orientation: 旋转方向
      // other: 其他附加信息
      const result = {
        width: imageInfo.size.width,
        height: imageInfo.size.height,
        size: 0, // 文件大小需要通过其他方式获取
      };

      imageSource.release();
      return result;
    } catch (err) {
      console.error('Get image info failed: ' + JSON.stringify(err));
      return { width: 0, height: 0, size: 0 };
    }
  }

  // ==================== 创建预览PixelMap ====================
  // 用于详情页面的大图预览
  async createPreviewPixelMap(uri: string) {
    try {
      const imageSource = image.createImageSource(uri);
      const decodingOptions: image.DecodingOptions = {
        editable: true,
        // 限制预览图的最大尺寸
        // 为什么限制?因为手机屏幕有限,加载太大的图浪费内存
        desiredSize: { width: 800, height: 1200 },
        desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
      };
      const pixelMap = await imageSource.createPixelMap(decodingOptions);
      imageSource.release();

      this.previewPixelMap = pixelMap;
    } catch (err) {
      console.error('Create preview failed: ' + JSON.stringify(err));
    }
  }

  // ==================== 添加卡片 ====================
  async addCards() {
    const uris = await this.pickImages();
    if (uris.length === 0) return;

    // 批量处理选中的图片
    let addedCount = 0;
    for (const uri of uris) {
      const info = await this.getImageInfo(uri);
      const newCard: CardData = {
        id: Date.now() + addedCount,
        name: '未命名卡片',
        imagePath: uri,
        width: info.width,
        height: info.height,
        fileSize: 0,
        rarity: 'common',
        date: new Date().toISOString().split('T')[0],
        description: '',
      };
      this.cards = [...this.cards, newCard];
      addedCount++;
    }

    promptAction.showToast({
      message: `成功添加 ${addedCount} 张卡片`,
      duration: 1500,
    });
  }

  // ==================== 更新稀有度 ====================
  updateRarity(cardId: number, rarityKey: string) {
    this.cards = this.cards.map(card =>
      card.id === cardId ? { ...card, rarity: rarityKey } : card
    );
    // 如果当前查看的卡片就是被更新的卡片,也要同步更新
    if (this.selectedCard && this.selectedCard.id === cardId) {
      this.selectedCard = { ...this.selectedCard, rarity: rarityKey };
    }
    const rarity = RARITY_LEVELS.find(r => r.key === rarityKey);
    promptAction.showToast({
      message: `稀有度已更新为"${rarity?.label}"`,
      duration: 1000,
    });
  }

  // ==================== 删除卡片 ====================
  deleteCard(id: number) {
    this.cards = this.cards.filter(card => card.id !== id);
    if (this.selectedCard && this.selectedCard.id === id) {
      this.selectedCard = null;
      this.showDetail = false;
    }
    // 释放预览PixelMap
    if (this.previewPixelMap) {
      this.previewPixelMap.release();
      this.previewPixelMap = null;
    }
    promptAction.showToast({ message: '卡片已删除', duration: 1000 });
  }

  // ==================== 获取稀有度配置 ====================
  getRarityConfig(key: string): RarityConfig {
    return RARITY_LEVELS.find(r => r.key === key) || RARITY_LEVELS[0];
  }

  // ==================== 格式化文件大小 ====================
  formatFileSize(bytes: number): string {
    if (bytes <= 0) return '未知';
    if (bytes < 1024) return bytes + ' B';
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
    return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
  }

  // ==================== 生成星级文字 ====================
  getStarsText(stars: number): string {
    // 用半角星号拼接
    let text = '';
    for (let i = 0; i < stars; i++) {
      text += '★';
    }
    return text;
  }

  // ==================== UI构建 ====================
  build() {
    Column() {
      // 标题栏
      Row() {
        Text('红卡志')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)
        Text(`共 ${this.cards.length} 张`)
          .fontSize(14)
          .fontColor('#999999')
        Button('添加卡片')
          .fontSize(13)
          .height(34)
          .backgroundColor('#FF6B6B')
          .fontColor('#FFFFFF')
          .margin({ left: 8 })
          .onClick(() => this.addCards())
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 8 })

      // 卡片网格
      if (this.cards.length === 0) {
        Column() {
          Text('还没有收藏的卡片')
            .fontSize(16)
            .fontColor('#999999')
          Text('点击"添加卡片"开始你的收藏之旅')
            .fontSize(13)
            .fontColor('#BBBBBB')
            .margin({ top: 4 })
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      }

      // 用WaterFlow组件实现瀑布流布局
      // WaterFlow比Grid更适合卡片展示,因为卡片可能有不同的长宽比
      WaterFlow() {
        ForEach(this.cards, (card: CardData) => {
          FlowItem() {
            Column() {
              // 卡片图片
              Image(card.imagePath)
                .width('100%')
                .height(card.height > card.width ? 180 : 140)
                .objectFit(ImageFit.Cover)
                .borderRadius({ topLeft: 8, topRight: 8 })

              // 卡片信息
              Column({ space: 2 }) {
                Text(card.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#333333')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })

                Row() {
                  Text(this.getRarityConfig(card.rarity).label)
                    .fontSize(11)
                    .fontColor(this.getRarityConfig(card.rarity).color)
                  Text(card.date)
                    .fontSize(11)
                    .fontColor('#999999')
                    .margin({ left: 6 })
                }

                Text(this.getStarsText(this.getRarityConfig(card.rarity).stars))
                  .fontSize(12)
                  .fontColor(this.getRarityConfig(card.rarity).color)
              }
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8, right: 8, top: 6, bottom: 8 })
            }
            .width('100%')
            .backgroundColor('#FFFFFF')
            .borderRadius(8)
            .border({ width: 1.5, color: this.getRarityConfig(card.rarity).color })
            .shadow({ radius: 4, color: 'rgba(0,0,0,0.08)', offsetY: 2 })
            .onClick(() => {
              this.selectedCard = card;
              this.showDetail = true;
              this.createPreviewPixelMap(card.imagePath);
            })
          }
        })
      }
      .columnsTemplate('1fr 1fr')
      .columnsGap(10)
      .rowsGap(10)
      .padding({ left: 12, right: 12 })
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
    // 详情弹层
    .bindSheet($$this.showDetail, this.DetailPanel(), { height: '70%' })
  }

  // ==================== 详情面板 ====================
  @Builder
  DetailPanel() {
    Column() {
      if (this.selectedCard) {
        const card = this.selectedCard;
        const rarity = this.getRarityConfig(card.rarity);

        // 关闭按钮
        Row() {
          Text('卡片详情')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .layoutWeight(1)
          Text('关闭')
            .fontSize(14)
            .fontColor('#999999')
            .onClick(() => {
              this.showDetail = false;
              this.selectedCard = null;
            })
        }
        .width('100%')
        .padding({ bottom: 12 })

        // 卡片大图
        Image(card.imagePath)
          .width('100%')
          .height(200)
          .objectFit(ImageFit.Contain)
          .backgroundColor('#F0F0F0')
          .borderRadius(8)

        // 卡片信息
        Column({ space: 6 }) {
          Row() {
            Text('名称: ')
              .fontSize(14)
              .fontColor('#666666')
            Text(card.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
          }
          Row() {
            Text('尺寸: ')
              .fontSize(14)
              .fontColor('#666666')
            Text(`${card.width} x ${card.height}`)
              .fontSize(14)
              .fontColor('#333333')
          }
          Row() {
            Text('收藏日期: ')
              .fontSize(14)
              .fontColor('#666666')
            Text(card.date)
              .fontSize(14)
              .fontColor('#333333')
          }
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding({ top: 12 })

        // 稀有度选择
        Column({ space: 8 }) {
          Text('稀有度')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(RARITY_LEVELS, (r: RarityConfig) => {
              Button(`${r.label} ${this.getStarsText(r.stars)}`)
                .fontSize(12)
                .height(32)
                .backgroundColor(card.rarity === r.key ? r.color : '#F5F5F5')
                .fontColor(card.rarity === r.key ? '#FFFFFF' : r.color)
                .border({ width: 1, color: r.color })
                .borderRadius(16)
                .margin({ right: 8, bottom: 6 })
                .onClick(() => this.updateRarity(card.id, r.key))
            })
          }
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding({ top: 12 })

        // 删除按钮
        Button('删除此卡片')
          .width('100%')
          .height(40)
          .fontSize(14)
          .backgroundColor('#FF6B6B')
          .fontColor('#FFFFFF')
          .margin({ top: 16 })
          .onClick(() => this.deleteCard(card.id))
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
  }
}

PhotoViewPicker的关键参数

PhotoSelectOptions里的MIMEType决定了选择器里显示什么类型的文件。IMAGE_TYPE只显示图片,VIDEO_TYPE只显示视频。maxSelectNumber控制一次最多选几张,我们设成9是因为卡片收集经常需要批量录入。

WaterFlow瀑布流布局

为什么用WaterFlow而不是Grid?因为卡片图片的长宽比可能不同。有的卡片是竖版(像球星卡),有的是横版(像集换式卡牌)。WaterFlow可以根据内容高度自动排列,视觉上比Grid整齐划一的格子好看得多。

bindSheet底部弹层

详情页面用bindSheet来实现底部弹出的面板,这是鸿蒙5.0之后推荐的模态交互方式。比传统的Dialog更适合展示大量内容的详情页面,用户可以上下滑动查看,也可以下拉关闭。


流程图

flowchart TD
    A[点击添加卡片] --> B[创建PhotoViewPicker]
    B --> C[设置IMAGE_TYPE]
    C --> D[设置maxSelectNumber=9]
    D --> E[await picker.select]
    E --> F[用户在相册中选择]
    F --> G[返回photoUris数组]
    G --> H[遍历每个URI]
    H --> I[image.createImageSource]
    I --> J[getImageInfo获取尺寸]
    J --> K[创建CardData对象]
    K --> L[添加到cards数组]
    L --> M[WaterFlow重新渲染]
    M --> N[用户点击卡片]
    N --> O[bindSheet弹出详情]
    O --> P[显示大图和信息]
    P --> Q{用户操作}
    Q -->|改稀有度| R[更新rarity字段]
    Q -->|删除| S[从数组移除]

React vs ArkTS 对比表

功能点React (Web)ArkTS (鸿蒙)
图片选择<input type="file">PhotoViewPicker.select()
图片预览FileReader.readAsDataURLImage组件直接用URI
图片信息new Image() + naturalWidth/Heightimage.createImageSource + getImageInfo
图片缩放CSS width/heightDecodingOptions.desiredSize
瀑布流布局CSS columns / masonry插件WaterFlow 组件
底部弹层自定义Modal/DrawerbindSheet
批量选择multiple 属性maxSelectNumber 参数
无需权限用户主动选择同样无需权限

PhotoViewPicker的设计哲学和Web的file input很像 -- 都是用户主动选择,不需要额外的权限授权。但体验上PhotoViewPicker好太多,它是系统原生界面,支持多选、预览、相册切换。


权限声明说明

{
  "module": {
    "reqPermissions": []
  }
}

和冰箱贴集一样,红卡志也不需要任何权限声明。因为我们用的是PhotoViewPicker让用户主动选择图片,不需要读取整个相册的权限。

如果你以后需要实现"自动扫描相册里的卡片照片"这种功能,那就需要ohos.permission.READ_MEDIA权限了。但主动选择的场景完全不需要。


总结

红卡志这篇文章我们搞明白了:

  1. PhotoViewPicker的使用 -- 创建实例、配置选项、调用select、获取photoUris
  2. 图片信息获取 -- image.createImageSource + getImageInfo拿到宽高尺寸
  3. WaterFlow瀑布流布局 -- 适合不同尺寸卡片的展示
  4. bindSheet底部弹层 -- 展示卡片详情的最佳方式

PhotoViewPicker是鸿蒙图片选择的推荐方式,记住"创建实例 -> 配置选项 -> await select -> 拿URI"这个四步流程就行。

好,去华为应用市场搜索"红卡志"下载体验,把你的卡片收藏管理起来吧。