如果你想做一个卡片收集App,需要用到这个API
想要亲自体验"红卡志"这款App的功能?打开你的鸿蒙手机,前往华为应用市场,搜索"红卡志"即可下载安装,把你的卡片收藏都数字化管理起来。
写在前面
卡片收集是一个很有趣的爱好。不管是球星卡、动漫卡、还是各种限定款,收集的过程本身就有一种成就感。但卡片多了之后,想找一张特定的卡反而变得困难 -- 到底放在哪个盒子里了?这张卡是什么时候收到的?价值多少?
"红卡志"就是帮你解决这些问题的App。拍照记录、分类管理、稀有度标记,把你的实体卡牌变成一个数字化的收藏库。这篇文章重点讲两个API:PhotoViewPicker(从相册选择卡片照片)和图片处理(获取图片信息并展示)。
这篇文章聊什么
- PhotoViewPicker的使用 -- 怎么让用户从相册里选择卡片的照片
- PixelMap的创建和显示 -- 怎么把选择的图片在App里展示出来
- 图片信息的获取和展示 -- 怎么获取图片的尺寸、格式等元数据
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.readAsDataURL | Image组件直接用URI |
| 图片信息 | new Image() + naturalWidth/Height | image.createImageSource + getImageInfo |
| 图片缩放 | CSS width/height | DecodingOptions.desiredSize |
| 瀑布流布局 | CSS columns / masonry插件 | WaterFlow 组件 |
| 底部弹层 | 自定义Modal/Drawer | bindSheet |
| 批量选择 | multiple 属性 | maxSelectNumber 参数 |
| 无需权限 | 用户主动选择 | 同样无需权限 |
PhotoViewPicker的设计哲学和Web的file input很像 -- 都是用户主动选择,不需要额外的权限授权。但体验上PhotoViewPicker好太多,它是系统原生界面,支持多选、预览、相册切换。
权限声明说明
{
"module": {
"reqPermissions": []
}
}
和冰箱贴集一样,红卡志也不需要任何权限声明。因为我们用的是PhotoViewPicker让用户主动选择图片,不需要读取整个相册的权限。
如果你以后需要实现"自动扫描相册里的卡片照片"这种功能,那就需要ohos.permission.READ_MEDIA权限了。但主动选择的场景完全不需要。
总结
红卡志这篇文章我们搞明白了:
- PhotoViewPicker的使用 -- 创建实例、配置选项、调用select、获取photoUris
- 图片信息获取 -- image.createImageSource + getImageInfo拿到宽高尺寸
- WaterFlow瀑布流布局 -- 适合不同尺寸卡片的展示
- bindSheet底部弹层 -- 展示卡片详情的最佳方式
PhotoViewPicker是鸿蒙图片选择的推荐方式,记住"创建实例 -> 配置选项 -> await select -> 拿URI"这个四步流程就行。
好,去华为应用市场搜索"红卡志"下载体验,把你的卡片收藏管理起来吧。