163 lines
4.5 KiB
TypeScript
163 lines
4.5 KiB
TypeScript
import { decode as base64Decode, encode as base64Encode } from 'base-64'
|
||
import * as FileSystem from 'expo-file-system/legacy'
|
||
|
||
/**
|
||
* 基于 Expo FileSystem 的持久化缓存系统,用于存储 .ani 文件数据
|
||
* 键是 URL,值是 ArrayBuffer 二进制数据
|
||
*/
|
||
|
||
const CACHE_FOLDER = `${FileSystem.cacheDirectory}ani_cache/`
|
||
|
||
/**
|
||
* 将 URL 转换为合法的文件名
|
||
*
|
||
* 为什么需要这样做?
|
||
* 1. URL 中包含文件系统不允许的特殊字符(如 /, :, ?, & 等)。
|
||
* 2. URL 的长度可能超过操作系统对文件名的长度限制(通常为 255 字符)。
|
||
* 3. 简单的哈希算法可以将任意长度的 URL 转换为简短且唯一的标识符。
|
||
*/
|
||
const hashUrl = (url: string): string => {
|
||
let hash = 0
|
||
for (let i = 0; i < url.length; i++) {
|
||
const char = url.charCodeAt(i)
|
||
hash = (hash << 5) - hash + char
|
||
hash |= 0 // Convert to 32bit integer
|
||
}
|
||
// 使用 hex 字符串和长度作为后缀来减少碰撞概率
|
||
return `${hash.toString(16)}_${url.length}`
|
||
}
|
||
|
||
/**
|
||
* 将 ArrayBuffer 转换为 Base64 字符串
|
||
*/
|
||
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
|
||
const bytes = new Uint8Array(buffer)
|
||
let binary = ''
|
||
for (let i = 0; i < bytes.byteLength; i++) {
|
||
binary += String.fromCharCode(bytes[i])
|
||
}
|
||
return base64Encode(binary)
|
||
}
|
||
|
||
/**
|
||
* 将 Base64 字符串转换为 ArrayBuffer
|
||
*/
|
||
const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
|
||
const binary = base64Decode(base64)
|
||
const bytes = new Uint8Array(binary.length)
|
||
for (let i = 0; i < binary.length; i++) {
|
||
bytes[i] = binary.charCodeAt(i)
|
||
}
|
||
return bytes.buffer
|
||
}
|
||
|
||
class AniStorage {
|
||
constructor() {
|
||
this.ensureDir()
|
||
}
|
||
|
||
/**
|
||
* 确保存储目录存在
|
||
*/
|
||
private async ensureDir() {
|
||
try {
|
||
// 直接尝试创建目录,intermediates: true 会处理父目录不存在的情况
|
||
// 如果目录已存在,通常不会报错,或者我们在 catch 中忽略
|
||
await FileSystem.makeDirectoryAsync(CACHE_FOLDER, { intermediates: true })
|
||
} catch (error) {
|
||
// 忽略目录已存在的错误
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取缓存文件路径
|
||
*/
|
||
private getFilePath(url: string): string {
|
||
const filename = hashUrl(url)
|
||
return `${CACHE_FOLDER}${filename}.ani`
|
||
}
|
||
|
||
/**
|
||
* 获取缓存中的 ani 数据
|
||
* @param url 资源的 URL
|
||
* @returns 缓存的 ArrayBuffer 数据,如果不存在则返回 undefined
|
||
*/
|
||
async get(url: string): Promise<ArrayBuffer> {
|
||
try {
|
||
const filePath = this.getFilePath(url)
|
||
// 使用 base64 编码读取二进制文件
|
||
const base64Content = await FileSystem.readAsStringAsync(filePath, {
|
||
encoding: FileSystem.EncodingType.Base64,
|
||
})
|
||
return base64ToArrayBuffer(base64Content)
|
||
} catch (error) {
|
||
// 文件不存在或读取失败时返回 undefined
|
||
throw Error(`AniCache get error: ${error}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置 ani 数据到缓存中
|
||
* @param url 资源的 URL
|
||
* @param data ArrayBuffer 数据
|
||
*/
|
||
async set(url: string, data: ArrayBuffer): Promise<void> {
|
||
try {
|
||
await this.ensureDir()
|
||
const filePath = this.getFilePath(url)
|
||
const base64Content = arrayBufferToBase64(data)
|
||
await FileSystem.writeAsStringAsync(filePath, base64Content, {
|
||
encoding: FileSystem.EncodingType.Base64,
|
||
})
|
||
} catch (error) {
|
||
console.warn('AniCache set error:', error)
|
||
throw Error(`AniCache set error: ${error}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查缓存中是否存在该 URL (异步)
|
||
* @param url 资源的 URL
|
||
*/
|
||
async has(url: string): Promise<boolean> {
|
||
try {
|
||
const filePath = this.getFilePath(url)
|
||
const info = await FileSystem.getInfoAsync(filePath)
|
||
return info.exists
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除所有缓存
|
||
*/
|
||
async clear(): Promise<void> {
|
||
try {
|
||
// idempotent: true 使得如果文件/目录不存在也不会报错
|
||
await FileSystem.deleteAsync(CACHE_FOLDER, { idempotent: true })
|
||
await this.ensureDir()
|
||
console.debug('AniCache cleared all cache')
|
||
} catch (error) {
|
||
console.warn('AniCache clear error:', error)
|
||
throw Error(`AniCache clear error: ${error}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除指定 URL 的缓存
|
||
* @param url 资源的 URL
|
||
*/
|
||
async delete(url: string): Promise<void> {
|
||
try {
|
||
const filePath = this.getFilePath(url)
|
||
await FileSystem.deleteAsync(filePath, { idempotent: true })
|
||
} catch (error) {
|
||
console.warn('AniCache delete error:', error)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 导出单例实例
|
||
export const aniStorage = new AniStorage()
|