阅读视图

发现新文章,点击刷新页面。

react native中实现视频转歌

先放图

微信图片_20260114172105_165_23.jpg

微信图片_20260114172111_166_23.jpg

微信图片_20260114172102_162_23.jpg

微信图片_20260114172103_163_23.jpg

前言

先解释一下视频转歌:它的本质是提取视频文件的音频部分为独立文件,底层技术原理是不是这样我也不是特别清楚。提取后存到设备内存中 ,音乐播放器可以读取播放该文件。该功能是我在某个音乐播放器应用上看到功能菜单有一个视频转歌功能,并简单尝试了,它的效果就是将选择的视频转换为音频,不过该软件转换后的文件是.vts格式,想要使用转换产物肯定要支持.vts或者了解这种文件怎么读取,至于为什么是这样大家应该都能理解,这也不是今天的重点。

项目环境关键依赖

在rn应用中,依赖版本对应性很重要,一旦有一点不匹配就会各种问题,各种失败,而且是使用expo创建的rn项目在需要原生功能时安装的依赖通常需要开发构建,下面是关键依赖以及版本:

  • "expo": "~54.0.31",
  • "kroog-ffmpeg-kit-react-native": "^6.0.9",
  • "react": "19.1.0",

实现视频转歌的核心依赖是kroog-ffmpeg-kit-react-native,这个依赖很明显是folk的分支,原包ffmpeg-kit好像已经不再维护,前段时间有个新闻说谷歌AI发现该依赖的漏洞,一段时间如果不修复,谷歌会公布漏洞,该项目团队回应说(通俗地说)你要是有良心就捐点money,你也是大用户,带头白嫖巴拉巴的。

核心依赖找到后,除了如何使用依赖外,流程非常清晰:选择视频文件->获取uri等信息->交给ffmpeg->转化->监听处理转换进度 ->更新进度->获取转换后的缓存结果->保存到设备:

1.选取视频文件

选取视频可以使用两种依赖库,分别是expo-media-library和expo-document-picker, 这里我们使用前者获取设备中MediaType.video类型的数据渲染成列表,供用户选择,选取后我们拿到uri

import { getAssetsAsync, MediaType, requestPermissionsAsync, type AssetsOptions } from 'expo-media-library';
    /**
     * 请求权限并获取视频列表
     *
     * 此方法会先请求媒体库读取权限,如果授权成功则调用 fetchVideos 方法加载视频数据。
     * 如果未获得权限,则弹出提示框告知用户。
     *
     * @param reset 是否重置当前已有的视频列表,默认为 false
     * @returns Promise<void>
     */
    const requestPermissionAndGetVideos = async (reset = true) => {
        try {
            setLoading(true);
            // 请求媒体库权限
            const { status } = await requestPermissionsAsync();
            if (status !== 'granted') {
                showNotification({ tip: '需要访问媒体库权限才能获取视频文件,请在设置中开启权限。', type: 'warning' });
                setHasPermission(false);
                return;
            }
            setHasPermission(true);
            // 获取视频文件,重新请求权限时默认重置列表
            await fetchVideos(reset);
        } catch (error) {
            showNotification({ tip: '获取视频文件失败,请重试', type: 'error' });
        } finally {
            setLoading(false);
        }
    };
        /**
         * 使用expo-media-library 获取视频文件列表
         * 和expo-document-picker 获取视频文件列表结果不太一样,expo-media-library 会返回更多的视频文件
         * 调用系统 API 获取指定范围内的视频资源,并更新状态以渲染到界面。
         * 支持分页加载与刷新功能。
         *
         * @param reset 是否清空已有数据后重新加载,默认为 false
         * @returns Promise<void>
         */
    const fetchVideos = async (reset = false) => {
        try {
            const options: AssetsOptions = {
                mediaType: MediaType.video, // 只获取视频文件
                first, // 每次加载根据列数动态调整
                after: reset ? undefined : after, // 分页加载
                sortBy: [['modificationTime', true]], // 按修改时间降序排序
            };
            const { assets = [], endCursor, hasNextPage } = await getAssetsAsync(options);
            const videoList = assets.map(asset => ({
                ...asset,
                durationString: formatTime(asset.duration),
            }));
            if (reset) {
                setVideos(videoList);
            } else {
                setVideos(prev => [...prev, ...videoList]);
            }
            setAfter(endCursor);
            setHasMore(hasNextPage);
        } catch (error) {
            showNotification({ tip: '获取视频列表失败,请重试', type: 'error' });
        }
    };

通过以上代码我们拿到视频列表,使用expo-media-library的好处在于可以分批获取视频文件,非常便于分页加载

2.核心操作 使用ffmpeg-kit从指定文件中提取音频: 当用户选取视频后我们存储uri,并使用expo-file-system检查文件uri是否存在,expo-file-system目前有新版和legacy版本,API区别很大,新版有三个核心依赖:Paths File Directory,API不像老版本直观,目前可以使用legacy版本,根据自己情况选择即可 文件存在则将文件存入缓存目录,使用FFmpegKi类执行命令: -i "${nativeInputPath}" -vn -c:a aac -b:a 192k "${nativeOutputPath}"; 目前该依赖库并不能转换成mp3但是可以转换成m4a格式音频:

import { FFmpegKit, type FFmpegSession, type Log, type FFmpegSessionCompleteCallback, type LogCallback } from 'kroog-ffmpeg-kit-react-native';

    /**
     * 异步处理视频转音频的核心逻辑函数。
     * 
     * 主要功能包括:
     * 1. 检查并创建用于 FFmpeg 的本地工作目录;
     * 2. 将选中的视频文件复制到缓存目录以确保访问权限;
     * 3. 使用 FFmpeg 提取音频流并进行格式转换;
     * 4. 在转换过程中更新进度条;
     * 5. 完成后将结果保存至应用私有目录,并提示用户;
     * 6. 包含详细的错误处理与取消机制支持。
     *
     * @param video - 视频资源对象,必须包含 uri 和其他必要元数据(如 duration、filename 等)。
     * @returns 无返回值。副作用包括 UI 更新、文件操作及可能的弹窗提示。
     */
    const handleConvertVideo = async (video: VideoAsset) => {
        /**
         * 检查当前是否有选中的视频文件,没有则提示用户选择视频文件
         */
        if (!video) {
            showNotification({ tip: '请选择视频文件', type: 'warning' });
            return;
        };
        /**保存视频资源对象 */
        convertVideoRef.current = video;
        /**
         * 先检查源文件是否存在,源文件不存在停止执行
         * 后续会检查缓存文件是否存在,
         * 如果存在就不缓存,直接使用缓存文件
         */
        const sourceFile = new File(video.uri);
        if (!sourceFile.exists) {
            showNotification({ tip: '视频文件不存在', type: 'error' });
            return;
        }
        const { document, cache } = Paths;
        /**
         *  存储转换后的音频文件用的FFmpeg 的本地工作目录
         */
        let ffmpegFolder: Directory | null = null;
        /**
         * 检测目录是否存在,不存在则创建
         */
        try {

            /**
             * 新版expo-file-system 检测文件是否是文件夹使用
             * Paths.info(uri)静态方法方法可以检测文件是否是文件夹
             * 返回对象{exists: boolean, isDirectory: boolean}
             * 或者直接使用new Dictory(ffmpegDir)返回对象,目录是否存在,不存在调用返回值create方法创建
             * 如果路径中包含中文文字必须使用encodeURI进行编码,否则报错
             * 使用encodeURI可以完整路径转码,使用encodeURIComponent
             * 可能转换过多导致路径报错,主要是路径中的中文名部分要转换
             * 第二个参数文件夹名前后写不写/都可以
             */

            ffmpegFolder = new Directory(Paths.document, MUSIC_FILE_FILE_NAME);
            if (!ffmpegFolder.exists) {
                let obj: DirectoryCreateOptions = { intermediates: true };
                /**
                 * 创建目录
                 */
                ffmpegFolder.create(obj)
            };
        } catch (error) {
            showNotification({ tip: '无法创建存储文件夹', type: 'error' });
            return;
        };
        /**
         * 开始转换视频为音频
         * 1. 复制视频到缓存目录
         * 2. 构建FFmpeg命令
         * 3. 执行转换
         * 4. 清理临时文件
         * 设置开始转换状态为true,重置进度值为0,并设置取消标志为false
         */
        setIsConverting(true);
        setProgress(0);
        cancelledRef.current = false;
        try {
            const { filename } = video;
            const baseName = filename ? filename.replace(/\.[^/.]+$/, '') : `video_${Date.now()}`;
            /**先将目标文件复制到缓存目录中 */
            const cacheDir = cache.uri ?? document.uri ?? '/';
            //方式1: 手动拼接文件要保存到缓存目录路径
            const cachedInputUri = `${cacheDir}${filename ?? baseName}`;
            cachedInputUriRef.current = cachedInputUri;
            // 方式2: 使用File构造函数链式创建目标文件
            // const cachedInputUri = new File(Paths.cache, filename ?? baseName).uri;
            try {
                /**
                 * 创建缓存目标文件对象检查是否存在
                 * 如果缓存文件存在,直接使用缓存文件,不存在则缓存视频文件
                 */
                const targetFile = new File(cachedInputUri);
                if (!targetFile.exists) {
                    sourceFile.copy(targetFile);
                };
            } catch (error) {
                showNotification({ tip: '复制视频到缓存目录失败', type: 'error' });
            };

            // 添加时间戳到文件名,避免文件名冲突
            const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
            /**
             * 输出文件名,不带extension,
             * 因为要生成图片时使用名称作为文件名,所以不能带扩展名
             * cachedOutputUri: 缓存输出文件路径
             * finalOutputUri: 最终输出文件路径
             */
            const outputFileName = `${baseName}_${timestamp}`;
            const cachedOutputUri = `${cacheDir}${outputFileName}.m4a`;
            const finalOutputUri = `${ffmpegFolder.uri}${outputFileName}.m4a`;
            outFileNameRef.current = outputFileName;
            // 保存临时文件路径用于清理
            cachedOutputUriRef.current = cachedOutputUri;
            lastOutputUriRef.current = finalOutputUri;
            /**
             * 创建进出路径,用于command命令拼接
             */
            const nativeInputPath = cachedInputUri.startsWith('file://') ? cachedInputUri.replace('file://', '') : cachedInputUri;
            const nativeOutputPath = cachedOutputUri.startsWith('file://') ? cachedOutputUri.replace('file://', '') : cachedOutputUri;
            /** 
             * 构建命令 暂不支持mp3,暂时使用m4a
             * 转换命令参考:
             * 
            */
            const command = `-i "${nativeInputPath}" -vn -c:a aac -b:a 192k "${nativeOutputPath}"`;
            /**
             * 转换完成回调函数
             * 1.获取转换码
             * 2.检查转换是否成功
             * 3.如果成功,检查是否取消,取消不保存文件
             * 4.如果未取消,保存输出文件
             * 5.清理临时文件
             * 6.创建视频缩略图保存到cover目录中
             * 7.将歌曲信息和图片信息写入musicList文件夹下的convertList.json文件中
             * @param session FFmpegSession
             * @returns 
             */
            const completeCallback: FFmpegSessionCompleteCallback = async (session: FFmpegSession) => {
                try {
                    // 获取转换码
                    const returnCode = await session.getReturnCode();
                    /**
                     * 检查是否成功
                     * 方法1:调用ReturnCode的成员方法isValueSuccess返回布尔值
                     * 方法2:调用ReturnCode的静态方法isSuccess传递ReturnCode对象实例然后返回
                     * 布尔值
                     * 成员方法getCode 返回ReturnCode对象实例的code属性值,0 是success 255 是cancel
                     */
                    /**方式1 调用成员方法检测 */
                    const success = returnCode.isValueSuccess();
                    /**方式2 调用成员方法getValue获取执行值,再调用静态方法isSuccess 检查是否成功 */
                    // success = ReturnCode.isSuccess(returnCode);
                    if (success) {
                        // 如果转换已取消,则跳过保存
                        if (cancelledRef.current) {
                            // 即使取消也要清理临时文件
                            try {
                                if (cachedInputUri) {
                                    new File(cachedInputUri).delete();
                                }
                            } catch (error) {
                                showNotification({ tip: '清理输入缓存文件失败', type: 'error' });
                            }
                            return;
                        };
                        // 检查输出文件是否存在且非空
                        try {
                            const { exists } = Paths.info(cachedOutputUriRef.current!);
                            if (!exists) {
                                showNotification({ tip: '生成的音频文件为空或不存在', type: 'error' });
                                return;
                            };
                        } catch (error) {
                            showNotification({ tip: '无法验证输出文件', type: 'error' });
                            return;
                        };
                        isSuccessRef.current = true;
                    } else {
                        /**
                         * 转换失败后获取状态码解释原因
                         */
                        const returnCodeValue = returnCode.getValue();
                        const config: Record<number, string> = {
                            255: '转换已取消',
                            1: '输入文件不存在或无法读取',
                            2: '输出路径无效或无写入权限',
                        }
                        let errorMessage = config[returnCodeValue] ?? `转换失败 (错误码: ${returnCodeValue})`;
                        showNotification({ tip: errorMessage, type: 'error' });
                    }
                } catch (e) {
                    showNotification({ tip: '转换过程中发生错误', type: 'error' });
                } finally {
                    cancelledRef.current = false;
                    setIsConverting(false);
                    setProgress(0);
                    if (isSuccessRef.current) {
                        setVisible(true);
                    }
                }
            };
            /**
             * 转换过程中日志处理,更新转换进度
             * 使用生产环境友好的日志回调
             * @param log Log 日志对象
             */
            // 为了兼容现有的日志解析逻辑,保留原有的进度计算
            const legacyLogCallback: LogCallback = (log: Log) => {
                const message = log.getMessage();
                if (message.includes('time=')) {
                    try {
                        const timeMatch = message.match(/time=(\d+):(\d+):(\d+\.\d+)/);
                        if (timeMatch) {
                            const hours = parseFloat(timeMatch[1]);
                            const minutes = parseFloat(timeMatch[2]);
                            const seconds = parseFloat(timeMatch[3]);
                            const currentTimeInSeconds = hours * 3600 + minutes * 60 + seconds;
                            const totalTimeInSeconds = (video && (video.duration ?? 0)) || 120;
                            const calculatedProgress = Math.min(100, (currentTimeInSeconds / totalTimeInSeconds) * 100);
                            setProgress(calculatedProgress);
                        }
                    } catch (error) {
                        if (__DEV__) {
                            showNotification({ tip: '进度解析失败', type: 'error' });
                        }
                    }
                }
            };
            /** 开始转换 */
            try {
                /**
                 * 存在如下方法
                 * FFmpegKit.execute(cmd): Promise<FFmpegSession>
                 * executeAsync(command: string, completeCallback?: FFmpegSessionCompleteCallback, logCallback?: LogCallback, statisticsCallback?: StatisticsCallback): Promise<FFmpegSession>
                 */
                conversionSessionRef.current = await FFmpegKit.executeAsync(command, completeCallback, legacyLogCallback);
            } catch (error) {
                showNotification({ tip: '转换过程中发生错误', type: 'error' });
            }
        } catch (e) {
            showNotification({ tip: '转换过程中发生错误', type: 'error' });
        } finally {
            setProgress(0);
        }
    };

completeCallback中是转换完成时执行的回调函数,而legacyLogCallback回调函数则是转换过程的日志回调函数 ,在该函数中可以做转换进度更新,也就是说我们核心就是使用调用conversionSessionRef.current = await FFmpegKit.executeAsync(command, completeCallback, legacyLogCallback); 并且在转换过程中添加取消转换和转换失败处理:基本效果就是如下:

微信图片_20260114164854_149_23.jpg

微信图片_20260114164855_150_23.jpg

3.取消转换

转换途中万一发现不是自己想要转换的,可以终止转换,我们要清理转换前创建的文件等:

    /**
     * 取消转换
     * @returns Promise<void>
     */
    const handleCancel = async () => {
        // mark as cancelled so callbacks skip saving
        cancelledRef.current = true;
        if (!conversionSessionRef.current) {
            setIsConverting(false);
            setProgress(0);
            return;
        };

        try {
            /**
             * 优先尝试 session.cancel()
             */
            await conversionSessionRef.current.cancel();
        } catch (error) {
            /**
             * 调用cancel方法失败,尝试通过 sessionId 使用 FFmpegKit.cancel
             */
            try {
                const sessionId = conversionSessionRef.current.getSessionId();
                await FFmpegKit.cancel(sessionId)
                showNotification({ tip: '已取消转换', type: 'success' });
            } catch (err) {
                showNotification({ tip: '取消转换失败', type: 'error' });
            }
        } finally {
            // 清理状态
            conversionSessionRef.current = null;
            setIsConverting(false);
            setProgress(0);
            // 删除临时输出文件,避免残留
            if (cachedOutputUriRef.current) {
                try {
                    const file = new File(cachedOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                cachedOutputUriRef.current = null;
            }
            if (lastOutputUriRef.current) {
                try {
                    const file = new File(lastOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                lastOutputUriRef.current = null;
            }
        }
    };

4.转换完成可以提示用户修改歌曲信息:

    /**
     * 保存转换后的文件
     * 将其从转换完成中提取出来,添加转换后可以填写信息,不填写则使用默认信息
     */
    const saveConvertFile = async (info?: UpdateSongInfoConfig) => {
        if (!convertVideoRef.current || !isSuccessRef.current) {
            return;
        };
        // 复制文件到应用私有目录
        try {
            const { uri, duration, durationString } = convertVideoRef.current;
            const file = new File(cachedOutputUriRef.current!);
            const privateFile = new File(lastOutputUriRef.current!);
            file.copy(privateFile);
            /**创建对应视频的封面图 */
            const coverUrl = await addAudioCover(uri, outFileNameRef.current!) ?? '';
            /**
             * 转换后的音频文件存在
             * 执行添加音频json文件操作
             */
            const { exists, creationTime, md5, uri: musicUri, modificationTime } = privateFile;
            if (exists) {
                /**
                 * 这里必须手动解构,无法直接...rest,File对象上的属性不可枚举
                 * 类的内部是get方法写的
                 * 调整为存储到数据表
                 */
                const { title = outFileNameRef.current!, artist = '未知歌手', album = '未知专辑' } = info ?? {};
                const time = Date.now()
                const obj: SqliteSongInfo = {
                    id: md5 || `${time}`,
                    uri: musicUri,
                    artist,
                    title,
                    duration: duration,
                    durationString,
                    modificationTime: modificationTime ?? time,
                    coverUrl,
                    isCollection: 0,
                    isPrivate: 1,
                    creationTime: creationTime ?? time,
                    album,
                    lyrics: '',
                };
                const bool = await addSong(obj, SQLITE_CONVERTED_TABLE_NAME);
                if (bool) {
                    convertVideoRef.current = null;
                    isSuccessRef.current = false;
                    const cacheInputFile = new File(cachedInputUriRef.current!);
                    if (cacheInputFile.exists) {
                        cacheInputFile.delete();
                        cachedInputUriRef.current = null;
                    }
                    /**删除转换后的缓存音频文件 */
                    file.delete();
                    showNotification({ tip: '音频文件已成功保存', type: 'success' });
                };
            };
        } catch (copyError) {
            showNotification({ tip: '无法保存音频文件到应用文件夹', type: 'error' });
        }
    }

微信图片_20260114164856_151_23.jpg

微信图片_20260114164857_152_23.jpg

用户可以确定和取消,都将保存转换后的文件

5.查看转换后的歌曲列表,在这里可以修改和删除:

微信图片_20260114164859_154_23.jpg

微信图片_20260114164900_155_23.jpg

微信图片_20260114164901_156_23.jpg

当前还可以播放:

微信图片_20260114170201_157_23.jpg

关于音乐播放,当前项目中使用了react-native-audio-pro和expo-audio,在转换管理界面的播放使用了exp-audio,其实目前rn音乐播放器使用react-native-track-player依赖库最好,但是对于新版本rn和expo有难以解决的bug,react-native-audio-pro则bug少一点

6.创建音频文件的封面图

核心使用expo-video-thumbnails依赖库,获取指定时间的图像,并设置图片质量,但是格式就只能是jpg

import { getThumbnailAsync, type VideoThumbnailsOptions } from 'expo-video-thumbnails';
/**
 * 为音频添加封面
 * 使用expo-video-thumbnails库生成音频封面
 * 将图片保存到应用私有目录的cover文件夹中,图片格式.jpg,默认最低画质
 * @param audioUri 音频uri
 * @param coverName 封面名称 通常与音频文件名相同
 * @returns 封面uri
 */
export const addAudioCover = async (audioUri: string, coverName: string, seconds: number = 2000): Promise<string | null> => {
    if (!coverName.trim()) {
        return null;
    }
    const { exists } = Paths.info(audioUri);
    if (!exists) {
        return null;
    };
    try {
        const obj: VideoThumbnailsOptions = {
            time: seconds,
            quality: .7,//0~1,0最低1最高
        };
        /**
         * 获取视频文件指定秒数的音频封面
         */
        const { uri } = await getThumbnailAsync(audioUri, obj);
        /**
         * 获取扩展名便于生成封面uri
         */
        const ext = uri.split('.').at(-1);
        const file = new File(uri);
        /**
         * 创建封面目录
         * 如果目录不存在则创建
         */
        const coverUrl = `${Paths.document.uri + COVER_FILE_FILE_NAME}/`
        const { exists, isDirectory } = Paths.info(coverUrl);
        if (!exists || !isDirectory) {
            new Directory(Paths.document, 'cover').create({ intermediates: true });
        };
        /**
         * 创建封面文件
         * 并copy到封面目录
         */
        const finnalUri = `${coverUrl + coverName}.${ext}`;
        /**
         *  检测是否重名
         */
        const info = Paths.info(finnalUri);
        if (info.exists) {
            return null;
        }
        file.copy(new File(finnalUri));
        file.delete();
        return finnalUri;
    } catch (error) {
        return null
    }
}

7.使用FFmpegKit获取已有mp3文件的封面图,并不是所有文件都有

import { FFmpegKit } from 'kroog-ffmpeg-kit-react-native';
/**
 * 使用FFmpeg提取MP3音频文件的封面图片
 * @param audioUri 音频文件的URI路径
 * @param coverName 封面图片的名称(不带扩展名)
 * @returns 封面图片的完整URI路径,如果提取失败返回null
 */
export const extractMp3Cover = async (audioUri: string, coverName: string): Promise<string | null> => {
    if (!audioUri || !coverName.trim()) {
        return null;
    }

    try {
        // 创建cover目录
        const coverDir = new Directory(Paths.document, 'cover');
        if (!coverDir.exists) {
            coverDir.create({ intermediates: true });
        }

        // 构建输出路径
        const coverPath = `${Paths.document.uri}cover/${coverName}.jpg`;
        const coverFile = new File(coverPath);

        // 检查封面是否已存在
        if (coverFile.exists) {
            return coverPath;
        }

        // 使用FFmpeg提取封面图片
        // -an: 不处理音频
        // -vcodec copy: 直接复制视频流(封面)
        // -y: 覆盖已存在的文件
        const command = `-i "${audioUri}" -an -vcodec copy -y "${coverPath}"`;
        const session = await FFmpegKit.execute(command);
        const returnCode = await session.getReturnCode();

        if (returnCode.isValueSuccess()) {
            // 检查文件是否真的创建成功
            if (coverFile.exists) {
                console.log(`成功提取封面: ${coverName}`);
                return coverPath;
            } else {
                // 如果文件没有创建,说明没有嵌入封面
                return null;
            }
        } else {
            const output = await session.getOutput();
            console.warn('提取封面失败:', output);
            return null;
        }
    } catch (error) {
        console.error('提取MP3封面出错:', error);
        return null;
    }
};

8.完整代码:

import { useRef, useState, useEffect, type FC } from 'react';
import TitleBar from '@/components/music/TitleBar';
import VideoList from '@/components/VideoList';
import { Ionicons } from '@expo/vector-icons';
import type { VideoAsset } from '@/types';
import { router } from 'expo-router';
import { FFmpegKit, type FFmpegSession, type Log, type FFmpegSessionCompleteCallback, type LogCallback } from 'kroog-ffmpeg-kit-react-native';
import { Modal, StyleSheet, View, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedView, ThemedText } from '@/components/theme/ThemedComponents';
import { useThemeNotification } from '@/hooks/useTheme';
import { File, Paths, Directory, type DirectoryCreateOptions } from 'expo-file-system';
import { addAudioCover } from '@/utils/audioManager';
import type { SqliteSongInfo, UpdateSongInfoConfig } from '@/types';
import UpdateOrDeleteModal from '@/components/music/localComp/UpdateOrDeleteModal';
import ProcessTip from '@/components/ui/ProcessTip';
import { addSong } from '@/libs/sqlite';
import { SQLITE_CONVERTED_TABLE_NAME, MUSIC_FILE_FILE_NAME } from '@/constants/SongList';
/**
 * 视频转歌曲页面
 * @returns 
 */
const FfmpegKitPage: FC = () => {
    const { top, bottom } = useSafeAreaInsets();
    const [isConverting, setIsConverting] = useState<boolean>(false);
    const [progress, setProgress] = useState(0);
    /**是否转换成功 */
    const isSuccessRef = useRef<boolean>(false);
    /**保存需要转换的视频文件 */
    const convertVideoRef = useRef<VideoAsset | null>(null);
    /**保存转换后的文件名 */
    const outFileNameRef = useRef<string | null>(null);
    const cachedInputUriRef = useRef<string | null>(null);
    // 添加状态管理
    const cachedOutputUriRef = useRef<string | null>(null);
    const lastOutputUriRef = useRef<string | null>(null);
    const conversionSessionRef = useRef<FFmpegSession | null>(null);
    const cancelledRef = useRef<boolean>(false);
    const showNotification = useThemeNotification();
    const [visible, setVisible] = useState<boolean>(false);
    const handleBack = () => {
        router.dismiss();
    };
    const cleanUp = async () => {
        if (cachedOutputUriRef.current) {
            const file = new File(cachedOutputUriRef.current);
            if (file.exists) {
                file.delete();
            }
        }
        cachedOutputUriRef.current &&= null;
        conversionSessionRef.current &&= null;
    };
    useEffect(() => {
        return () => {
            cleanUp();
        }
    }, [])
    /**
     * 保存转换后的文件
     * 将其从转换完成中提取出来,添加转换后可以填写信息,不填写则使用默认信息
     */
    const saveConvertFile = async (info?: UpdateSongInfoConfig) => {
        if (!convertVideoRef.current || !isSuccessRef.current) {
            return;
        };
        // 复制文件到应用私有目录
        try {
            const { uri, duration, durationString } = convertVideoRef.current;
            const file = new File(cachedOutputUriRef.current!);
            const privateFile = new File(lastOutputUriRef.current!);
            file.copy(privateFile);
            /**创建对应视频的封面图 */
            const coverUrl = await addAudioCover(uri, outFileNameRef.current!) ?? '';
            /**
             * 转换后的音频文件存在
             * 执行添加音频json文件操作
             */
            const { exists, creationTime, md5, uri: musicUri, modificationTime } = privateFile;
            if (exists) {
                /**
                 * 这里必须手动解构,无法直接...rest,File对象上的属性不可枚举
                 * 类的内部是get方法写的
                 * 调整为存储到数据表
                 */
                const { title = outFileNameRef.current!, artist = '未知歌手', album = '未知专辑' } = info ?? {};
                const time = Date.now()
                const obj: SqliteSongInfo = {
                    id: md5 || `${time}`,
                    uri: musicUri,
                    artist,
                    title,
                    duration: duration,
                    durationString,
                    modificationTime: modificationTime ?? time,
                    coverUrl,
                    isCollection: 0,
                    isPrivate: 1,
                    creationTime: creationTime ?? time,
                    album,
                    lyrics: '',
                };
                const bool = await addSong(obj, SQLITE_CONVERTED_TABLE_NAME);
                if (bool) {
                    convertVideoRef.current = null;
                    isSuccessRef.current = false;
                    const cacheInputFile = new File(cachedInputUriRef.current!);
                    if (cacheInputFile.exists) {
                        cacheInputFile.delete();
                        cachedInputUriRef.current = null;
                    }
                    /**删除转换后的缓存音频文件 */
                    file.delete();
                    showNotification({ tip: '音频文件已成功保存', type: 'success' });
                };
            };
        } catch (copyError) {
            showNotification({ tip: '无法保存音频文件到应用文件夹', type: 'error' });
        }
    }
    /**
     * 异步处理视频转音频的核心逻辑函数。
     * 
     * 主要功能包括:
     * 1. 检查并创建用于 FFmpeg 的本地工作目录;
     * 2. 将选中的视频文件复制到缓存目录以确保访问权限;
     * 3. 使用 FFmpeg 提取音频流并进行格式转换;
     * 4. 在转换过程中更新进度条;
     * 5. 完成后将结果保存至应用私有目录,并提示用户;
     * 6. 包含详细的错误处理与取消机制支持。
     *
     * @param video - 视频资源对象,必须包含 uri 和其他必要元数据(如 duration、filename 等)。
     * @returns 无返回值。副作用包括 UI 更新、文件操作及可能的弹窗提示。
     */
    const handleConvertVideo = async (video: VideoAsset) => {
        /**
         * 检查当前是否有选中的视频文件,没有则提示用户选择视频文件
         */
        if (!video) {
            showNotification({ tip: '请选择视频文件', type: 'warning' });
            return;
        };
        /**保存视频资源对象 */
        convertVideoRef.current = video;
        /**
         * 先检查源文件是否存在,源文件不存在停止执行
         * 后续会检查缓存文件是否存在,
         * 如果存在就不缓存,直接使用缓存文件
         */
        const sourceFile = new File(video.uri);
        if (!sourceFile.exists) {
            showNotification({ tip: '视频文件不存在', type: 'error' });
            return;
        }
        const { document, cache } = Paths;
        /**
         *  存储转换后的音频文件用的FFmpeg 的本地工作目录
         */
        let ffmpegFolder: Directory | null = null;
        /**
         * 检测目录是否存在,不存在则创建
         */
        try {

            /**
             * 新版expo-file-system 检测文件是否是文件夹使用
             * Paths.info(uri)静态方法方法可以检测文件是否是文件夹
             * 返回对象{exists: boolean, isDirectory: boolean}
             * 或者直接使用new Dictory(ffmpegDir)返回对象,目录是否存在,不存在调用返回值create方法创建
             * 如果路径中包含中文文字必须使用encodeURI进行编码,否则报错
             * 使用encodeURI可以完整路径转码,使用encodeURIComponent
             * 可能转换过多导致路径报错,主要是路径中的中文名部分要转换
             * 第二个参数文件夹名前后写不写/都可以
             */

            ffmpegFolder = new Directory(Paths.document, MUSIC_FILE_FILE_NAME);
            if (!ffmpegFolder.exists) {
                let obj: DirectoryCreateOptions = { intermediates: true };
                /**
                 * 创建目录
                 */
                ffmpegFolder.create(obj)
            };
        } catch (error) {
            showNotification({ tip: '无法创建存储文件夹', type: 'error' });
            return;
        };
        /**
         * 开始转换视频为音频
         * 1. 复制视频到缓存目录
         * 2. 构建FFmpeg命令
         * 3. 执行转换
         * 4. 清理临时文件
         * 设置开始转换状态为true,重置进度值为0,并设置取消标志为false
         */
        setIsConverting(true);
        setProgress(0);
        cancelledRef.current = false;
        try {
            const { filename } = video;
            const baseName = filename ? filename.replace(/\.[^/.]+$/, '') : `video_${Date.now()}`;
            /**先将目标文件复制到缓存目录中 */
            const cacheDir = cache.uri ?? document.uri ?? '/';
            //方式1: 手动拼接文件要保存到缓存目录路径
            const cachedInputUri = `${cacheDir}${filename ?? baseName}`;
            cachedInputUriRef.current = cachedInputUri;
            // 方式2: 使用File构造函数链式创建目标文件
            // const cachedInputUri = new File(Paths.cache, filename ?? baseName).uri;
            try {
                /**
                 * 创建缓存目标文件对象检查是否存在
                 * 如果缓存文件存在,直接使用缓存文件,不存在则缓存视频文件
                 */
                const targetFile = new File(cachedInputUri);
                if (!targetFile.exists) {
                    sourceFile.copy(targetFile);
                };
            } catch (error) {
                showNotification({ tip: '复制视频到缓存目录失败', type: 'error' });
            };

            // 添加时间戳到文件名,避免文件名冲突
            const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
            /**
             * 输出文件名,不带extension,
             * 因为要生成图片时使用名称作为文件名,所以不能带扩展名
             * cachedOutputUri: 缓存输出文件路径
             * finalOutputUri: 最终输出文件路径
             */
            const outputFileName = `${baseName}_${timestamp}`;
            const cachedOutputUri = `${cacheDir}${outputFileName}.m4a`;
            const finalOutputUri = `${ffmpegFolder.uri}${outputFileName}.m4a`;
            outFileNameRef.current = outputFileName;
            // 保存临时文件路径用于清理
            cachedOutputUriRef.current = cachedOutputUri;
            lastOutputUriRef.current = finalOutputUri;
            /**
             * 创建进出路径,用于command命令拼接
             */
            const nativeInputPath = cachedInputUri.startsWith('file://') ? cachedInputUri.replace('file://', '') : cachedInputUri;
            const nativeOutputPath = cachedOutputUri.startsWith('file://') ? cachedOutputUri.replace('file://', '') : cachedOutputUri;
            /** 
             * 构建命令 暂不支持mp3,暂时使用m4a
             * 
             */
            const command = `-i "${nativeInputPath}" -vn -c:a aac -b:a 192k "${nativeOutputPath}"`;
            /**
             * 转换完成回调函数
             * 1.获取转换码
             * 2.检查转换是否成功
             * 3.如果成功,检查是否取消,取消不保存文件
             * 4.如果未取消,保存输出文件
             * 5.清理临时文件
             * 6.创建视频缩略图保存到cover目录中
             * 7.将歌曲信息和图片信息写入SQlite数据库表
             * @param session FFmpegSession
             * @returns 
             */
            const completeCallback: FFmpegSessionCompleteCallback = async (session: FFmpegSession) => {
                try {
                    // 获取转换码
                    const returnCode = await session.getReturnCode();
                    /**
                     * 检查是否成功
                     * 方法1:调用ReturnCode的成员方法isValueSuccess返回布尔值
                     * 方法2:调用ReturnCode的静态方法isSuccess传递ReturnCode对象实例然后返回
                     * 布尔值
                     * 成员方法getCode 返回ReturnCode对象实例的code属性值,0 是success 255 是cancel
                     */
                    /**方式1 调用成员方法检测 */
                    const success = returnCode.isValueSuccess();
                    /**方式2 调用成员方法getValue获取执行值,再调用静态方法isSuccess 检查是否成功 */
                    // success = ReturnCode.isSuccess(returnCode);
                    if (success) {
                        // 如果转换已取消,则跳过保存
                        if (cancelledRef.current) {
                            // 即使取消也要清理临时文件
                            try {
                                if (cachedInputUri) {
                                    new File(cachedInputUri).delete();
                                }
                            } catch (error) {
                                showNotification({ tip: '清理输入缓存文件失败', type: 'error' });
                            }
                            return;
                        };
                        // 检查输出文件是否存在且非空
                        try {
                            const { exists } = Paths.info(cachedOutputUriRef.current!);
                            if (!exists) {
                                showNotification({ tip: '生成的音频文件为空或不存在', type: 'error' });
                                return;
                            };
                        } catch (error) {
                            showNotification({ tip: '无法验证输出文件', type: 'error' });
                            return;
                        };
                        isSuccessRef.current = true;
                    } else {
                        /**
                         * 转换失败后获取状态码解释原因
                         */
                        const returnCodeValue = returnCode.getValue();
                        const config: Record<number, string> = {
                            255: '转换已取消',
                            1: '输入文件不存在或无法读取',
                            2: '输出路径无效或无写入权限',
                        }
                        let errorMessage = config[returnCodeValue] ?? `转换失败 (错误码: ${returnCodeValue})`;
                        showNotification({ tip: errorMessage, type: 'error' });
                    }
                } catch (e) {
                    showNotification({ tip: '转换过程中发生错误', type: 'error' });
                } finally {
                    cancelledRef.current = false;
                    setIsConverting(false);
                    setProgress(0);
                    if (isSuccessRef.current) {
                        setVisible(true);
                    }
                }
            };
            /**
             * 转换过程中日志处理,更新转换进度
             * 使用生产环境友好的日志回调
             * @param log Log 日志对象
             */
            // 为了兼容现有的日志解析逻辑,保留原有的进度计算
            const legacyLogCallback: LogCallback = (log: Log) => {
                const message = log.getMessage();
                if (message.includes('time=')) {
                    try {
                        const timeMatch = message.match(/time=(\d+):(\d+):(\d+\.\d+)/);
                        if (timeMatch) {
                            const hours = parseFloat(timeMatch[1]);
                            const minutes = parseFloat(timeMatch[2]);
                            const seconds = parseFloat(timeMatch[3]);
                            const currentTimeInSeconds = hours * 3600 + minutes * 60 + seconds;
                            const totalTimeInSeconds = (video && (video.duration ?? 0)) || 120;
                            const calculatedProgress = Math.min(100, (currentTimeInSeconds / totalTimeInSeconds) * 100);
                            setProgress(calculatedProgress);
                        }
                    } catch (error) {
                        if (__DEV__) {
                            showNotification({ tip: '进度解析失败', type: 'error' });
                        }
                    }
                }
            };
            /** 开始转换 */
            try {
                /**
                 * 存在如下方法
                 * FFmpegKit.execute(cmd): Promise<FFmpegSession>
                 * executeAsync(command: string, completeCallback?: FFmpegSessionCompleteCallback, logCallback?: LogCallback, statisticsCallback?: StatisticsCallback): Promise<FFmpegSession>
                 */
                conversionSessionRef.current = await FFmpegKit.executeAsync(command, completeCallback, legacyLogCallback);
            } catch (error) {
                showNotification({ tip: '转换过程中发生错误', type: 'error' });
            }
        } catch (e) {
            showNotification({ tip: '转换过程中发生错误', type: 'error' });
        } finally {
            setProgress(0);
        }
    };
    const handleConvertManage = () => {
        router.push('/ConvertManage');
    };
    /**
     * 取消转换
     * @returns Promise<void>
     */
    const handleCancel = async () => {
        // 修改标记
        cancelledRef.current = true;
        if (!conversionSessionRef.current) {
            setIsConverting(false);
            setProgress(0);
            return;
        };

        try {
            /**
             * 优先尝试 session.cancel()
             */
            await conversionSessionRef.current.cancel();
        } catch (error) {
            /**
             * 调用cancel方法失败,尝试通过 sessionId 使用 FFmpegKit.cancel
             */
            try {
                const sessionId = conversionSessionRef.current.getSessionId();
                await FFmpegKit.cancel(sessionId)
                showNotification({ tip: '已取消转换', type: 'success' });
            } catch (err) {
                showNotification({ tip: '取消转换失败', type: 'error' });
            }
        } finally {
            // 清理状态
            conversionSessionRef.current = null;
            setIsConverting(false);
            setProgress(0);
            // 删除临时输出文件,避免残留
            if (cachedOutputUriRef.current) {
                try {
                    const file = new File(cachedOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                cachedOutputUriRef.current = null;
            }
            if (lastOutputUriRef.current) {
                try {
                    const file = new File(lastOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                lastOutputUriRef.current = null;
            }
        }
    };
    /**
     * 确定修改信息
     * @param info 
     * @returns Promise<void>
     */
    const handleConfirm = async (info?: UpdateSongInfoConfig | boolean) => {
        if (!convertVideoRef.current || typeof info === 'boolean') {
            return;
        }
        try {
            await saveConvertFile(info)
            setVisible(false);
        } catch (error) {
            showNotification({ tip: '更新文件信息失败,请检查文件权限', type: 'error' });
        }
    };
    /**
     * 取消修改 按默认名称保存
     * @returns Promise<void>
     */
    const handleCancelModify = async () => {
        await saveConvertFile();
    };
    return (
        <ThemedView style={[styles.container, { paddingTop: top, paddingBottom: bottom + 10 }]}>
            <TitleBar
                style={styles.top}
                leftComponent={
                    <Pressable
                        onPress={handleBack}
                        style={styles.topLeft}
                    >
                        <Ionicons name="chevron-back" size={22} color="#fff" />
                        <ThemedText style={styles.title}>视频转歌</ThemedText>
                    </Pressable>
                }
                rightComponent={
                    <ThemedText
                        style={styles.title}
                        onPress={handleConvertManage}
                    >
                        转换管理
                    </ThemedText>
                }
            />
            <VideoList
                onPress={handleConvertVideo}
            />
            <Modal
                visible={isConverting}
                transparent
                animationType="fade"
            >
                <View style={styles.modalContainer}>
                    < ProcessTip
                        progress={progress}
                        handleCancel={handleCancel}
                    />
                </View>
            </Modal>
            <UpdateOrDeleteModal
                visible={visible}
                needDeleteFile={false}
                setVisible={() => setVisible(false)}
                onConfirm={handleConfirm}
                onCancel={handleCancelModify}
                modalType='rename'
                audio={convertVideoRef.current as VideoAsset}
                fileName={outFileNameRef.current as string}
            />
        </ThemedView>
    );
};

const styles = StyleSheet.create({
    container: {
        flex: 1,
        alignItems: 'center',
    },
    top: {
        width: '100%',
    },
    title: {
        fontSize: 15,
        textAlign: 'center',
        fontWeight: 'bold',
    },
    topLeft: {
        flexDirection: 'row',
        alignItems: 'center',
    },
    modalContainer: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
    },
});

export default FfmpegKitPage;

小结

开始咱们说流程很清晰,关键在于获取要转换的文件转换文件和保存文件以及后续的修改,具体的细节其实还是比较多 的,但是代码没有什么高深的特技,就像这篇文件一样,平铺直叙。如果说有困难应该在FFmpegKit依赖库的文档方面,因为这个依赖库好像已经没人维护了,有些API我是看的node_modules里的TS类型定义和AI辅助编写的,刚开始即便借助AI也是囫囵吞枣,出现大量转换失败的情况,然后就是调试过程中产生大量缓存文件没有清理,后来打印输出缓存也是挺有美感

123.png

文笔非常一般,但是也算干货满满,文笔不行图片来凑,而且今天我打包了APK安装到手机上,目前测很流畅,比开发构建版本的运行流畅,目前该应用只开发了android版本,ios没有设没有针对性处理调试,接下来再放一些其他模块图片,但是其他有技术难度的值得宣讲的不多,因为该应用没有配套后台,纯本地,就涉及到了数据存取问题,就是使用了Sqlite数据库,后面有时间考虑写一篇文章讲解项目中歌单存取处理,多个单切换设计,播放队列管理等,再放点图吧,上面细节不明白的有问题的欢迎留言探讨

微信图片_20260114172112_167_23.jpg

微信图片_20260114172113_168_23.jpg

微信图片_20260114172114_169_23.jpg

微信图片_20260114172115_170_23.jpg

微信图片_20260114172116_171_23.jpg

ReactNative性能优化实战指南(2026最新版)

大家好,我是你们的 RN 老司机。

今天来聊聊 React Native 开发中最头疼的问题——性能优化。在 2026 年初,React Native 已经进入成熟期,新架构(The Architecture,前身为 New Architecture)已成为默认和强制标准,Hermes 引擎也进一步进化。很多老项目如果不优化,卡顿、掉帧、启动慢的问题会越来越明显,用户体验直线下降。

本文基于 React Native 最新版本(0.82+)和社区最佳实践,总结一套实用优化 checklist,帮助你让 App 跑得飞起,接近原生体验!

1. 基础必备:拥抱新架构 + Hermes 引擎

2025 年底的 React Native 0.82 版本是一个里程碑——旧架构彻底移除,新架构成为唯一选择。这意味着:

  • Fabric 渲染器:更高效的 UI 更新,Text 渲染速度提升 20%+。
  • TurboModules:按需懒加载 Native 模块。
  • JSI 接口:JS 和 Native 直接通信,告别旧 Bridge 的序列化瓶颈。

同时,Hermes 引擎仍是默认 JS 引擎,并在 0.82 中引入实验性新版本,进一步降低内存占用、加速冷启动。

优化建议

  • 新项目直接用最新 RN 初始化。
  • 老项目尽快迁移(社区库大多已适配)。
  • 启用 Hermes:Android/iOS 默认开启,结合 AOT 字节码预编译,启动时间可减 30-50%。

2. 减少不必要渲染:React 的核心优化

RN 最常见卡顿源于过度渲染。

关键技巧

  • 函数组件用 React.memo() 包裹。
  • useMemo() 缓存复杂计算,useCallback() 缓存回调函数。
  • 避免内联函数/对象(如 onPress={() => {...}})。
  • 类组件用 PureComponent 或手动 shouldComponentUpdate

这些小改动能减少 50%+ 的重渲染!

3. 列表优化:FlatList 的致命一击

长列表是性能杀手!用好 FlatList 的这些 props:

  • keyExtractor:用稳定唯一 ID(别用 index)。
  • getItemLayout:固定高度项必备,跳过测量,大幅提升滚动。
  • initialNumToRender:10-20。
  • windowSize:调小减内存。
  • removeClippedSubviews:移除视口外视图。
  • 社区推荐:换用 FlashList,性能更猛。

4. 图片与资源优化

大图是内存黑洞。

  • react-native-fast-image 支持缓存、优先级加载。
  • 压缩图片,用 WebP 格式,适配不同屏幕尺寸。
  • 懒加载:结合列表的 viewability 回调。

5. 动画与交互:跑在 Native 线程

别让 JS 线程阻塞!

  • Reanimated 3+ + Gesture Handler,动画直接在 UI/Native 线程运行,轻松 60FPS。
  • 复杂交互避开 JS 计算。

6. 启动速度与包体积优化

  • 代码拆分:动态 import 或 RamBundle。
  • 懒加载非首屏组件(React.lazy + Suspense)。
  • 启用 ProGuard(Android),移除无用库,压缩资源。

7. 其他进阶优化

  • 内存管理:清除定时器、监听器,避免泄漏。
  • 状态管理:用 Redux Toolkit + selector,避免全局重渲染。
  • 监控工具:Flipper、Systrace、Sentry 性能监控,先定位瓶颈再优化。

性能优化 Checklist

类别 关键优化点 预期收益
架构/引擎 新架构 + Hermes 启动快 30%+,内存低
渲染 memo / useMemo / useCallback 重渲染减 50%+
列表 FlatList 核心 props / FlashList 滚动丝滑,无掉帧
图片 FastImage + 压缩 加载更快,内存优化
动画 Reanimated 稳定 60FPS
启动 代码拆分 + 懒加载 首屏秒开

结语

2026 年的 React Native 已经非常强大,但性能优化永远是开发者必修课。先用工具定位问题(推荐 Flipper Performance),再针对性优化,往往事半功倍。

JSI入门指南

JSI入门指南

前言

要想彻底理解React Native新架构,JSI是绕不过去的槛。所以本文作为React Native源码剖析的第二篇文章。阅读本文需要了解一些简单的现代C++知识。新架构RN基本上是基于C++ 17开发的。学习现代C++可以极大的扩宽知识边界,提升能力范围。而且现代C++更加规范,学习难度降低,是性价比极高的选择。当然,仅作为源码阅读的需要,并不用深入学习C++,甚至不用会写代码,只要能看懂一些语法足够了。如果想更进一步,学习基本的现代C++开发,可以帮助我们开发React Native的纯C++ TurboModule,可以极大的优化RN App的性能,以及应用范围,在后面的RN新架构的一些列介绍文章中,相信对这一点一定体会极深。

JSI 概述

什么是JSI?

JSI(JavaScript Interface) 是 React Native 新架构中的一个核心组件,本质上是一个由 C++ 实现的轻量级接口层,用来连接:

  • 一边是 JavaScript 运行时(如 Hermes、V8)
  • 另一边是原生代码(主要是 C++,间接连到 Java/Obj‑C/平台 API)

它的目标是取代旧的异步 Bridge(JSON 消息桥),让 JS 与原生之间可以 直接、高性能地互相调用,大幅降低通信开销和延迟。

简单说,以前 JS 和原生要靠发 JSON 消息来通信,现在通过 JSI,双方可以像直接函数调用那样对话,并且可以互相保存对方对象的引用。

JSI 的核心特点

  1. 直接、低开销的 JS ↔ 原生通信

    • JSI 允许 JavaScript 保存 C++ 对象引用,C++ 也能保存 JS 对象引用,通过内存引用直接调用方法

    • 不再需要把数据转成 JSON 再跨线程传递,去掉序列化/反序列化开销,尤其对大数据量(如相机帧缓冲、图像、音频样本等)非常关键

    • 性能密集型库(例如 VisionCamera、Reanimated、Skia 等)借助 JSI 才能在 React Native 里做到接近原生的实时性能

  2. 支持同步和异步调用

    • 旧 Bridge 只能异步,很多场景需要“立刻拿到结果”很别扭。

    • JSI 提供:

      • 异步调用:常规推荐方式,避免阻塞 JS 线程或 UI 线程。

      • 同步调用:在确实需要“立即返回值”的场景(例如获取剪贴板、当前位置等)可以直接从原生拿值返回给 JS,而不用 Promise / 回调

      • 这种“既支持同步又支持异步”的模式,让 React Native 在易用性和性能之间有了更多空间。

  3. 用 C++ 实现、为性能而生

    • JSI 自身是 C++ API,提供 jsi::Runtimejsi::Objectjsi::Functionjsi::Value 等低层接口,用于:

      • 在原生代码中操作 JS 值与对象;
      • 注册可供 JS 调用的原生函数;
      • 直接与 JS 引擎交互
      • 由于是本地编译代码,减少了中间层和解释开销,带来更好的 启动时间与运行时性能
  4. 脱离旧 Bridge,成为新架构的基础。React Native 新架构的几个关键词:JSI、TurboModules、Fabric、Codegen,其中 JSI 是整个系统的“底座”

    • TurboModules:新一代原生模块系统,通过 JSI 实现 JS 与原生模块的直接调用,而不是通过老 Bridge

    • Fabric 渲染器:UI 事件与渲染更新通过 JSI 与 JS 运行时通信,使得界面更新更高效、更可控

    • Codegen:从类型化的 JS/TS 声明自动生成原生 C++/平台代码,这些代码通过 JSI 与 JS 通信

    • JSI 还是一个 与 JS 引擎无关的接口层,因此可以支持 Hermes、V8 等多种引擎,不再绑定于 JavaScriptCore

  5. 更适合高性能与跨平台原生模块。借助 JSI,你可以:

    • 写 纯 C++ 模块,然后在 Android 和 iOS 上复用这一套实现,只需很少的平台胶水代码
    • 直接在原生层访问设备能力(相机、蓝牙、GPS 等),并暴露给 JS 使用,同时保持高性能
    • 将复杂实例对象(数据库连接、图像缓冲、音频样本等)直接暴露给 JS 层,而不是一堆 JSON 数据
JSI vs Bridge
特性 Bridge (旧架构) JSI (新架构)
通信方式 JSON 序列化/反序列化 直接内存访问
性能 慢(每次调用都序列化) 快(零序列化开销)
同步调用 ❌ 不支持 ✅ 支持
类型安全 ❌ 运行时检查 ✅ C++ 类型系统
内存开销 高(JSON 字符串) 低(直接引用)

JSI 类型系统

类型层级结构
ICast (接口)
  └─ Runtime (抽象类)

Pointer (基类 - 不可拷贝,可移动)
  ├─ PropNameID (属性名)
  ├─ Symbol (ES6 Symbol)
  ├─ BigInt (大整数)
  ├─ String (字符串)
  └─ Object (对象)
       ├─ Array (数组)
       ├─ ArrayBuffer (二进制缓冲区)
       └─ Function (函数)

Value (联合类型 - 可存储任意 JS 值)
  ├─ undefined
  ├─ null
  ├─ boolean
  ├─ number
  ├─ Symbol
  ├─ BigInt
  ├─ String
  └─ Object
Runtime - JS 引擎抽象
class Runtime : public ICast {
 public:
  // 1. 执行 JavaScript 代码
  virtual Value evaluateJavaScript(
      const std::shared_ptr<const Buffer>& buffer,
      const std::string& sourceURL) = 0;

  // 2. 创建 JS 对象
  virtual Object createObject() = 0;
  virtual Array createArray(size_t length) = 0;
  virtual Function createFunctionFromHostFunction(
      const PropNameID& name,
      unsigned int paramCount,
      HostFunctionType func) = 0;

  // 3. 访问全局对象
  virtual Object global() = 0;

  // 4. 属性操作
  virtual Value getProperty(const Object&, const PropNameID& name) = 0;
  virtual void setPropertyValue(
      const Object&, const PropNameID& name, const Value& value) = 0;

  // 5. 微任务队列管理
  virtual void queueMicrotask(const Function& callback) = 0;
  virtual bool drainMicrotasks(int maxMicrotasksHint = -1) = 0;
};

引擎的具体实现(如:Hermes、JSC、V8)

// Hermes 实现示例
class HermesRuntime : public Runtime {
 public:
  Value evaluateJavaScript(...) override {
    // Hermes 特定的 JS 执行逻辑
  }

  Object createObject() override {
    // 调用 Hermes API 创建对象
  }
};
Value - 通用 JS 值类型
class Value {
 private:
  enum ValueKind {
    UndefinedKind,
    NullKind,
    BooleanKind,
    NumberKind,
    SymbolKind,
    BigIntKind,
    StringKind,
    ObjectKind,
  };

  union Data {
    bool boolean;
    double number;
    Pointer pointer;  // Symbol/String/Object
  };

  ValueKind kind_;
  Data data_;  // 8 字节(64 位)
};

类型检查与转换

void processValue(Runtime& runtime, const Value& value) {
  if (value.isUndefined()) {
    // 处理 undefined
  } else if (value.isNull()) {
    // 处理 null
  } else if (value.isBool()) {
    bool b = value.getBool();  // 断言检查
    bool b2 = value.asBool();  // 抛出异常
  } else if (value.isNumber()) {
    double d = value.getNumber();
  } else if (value.isString()) {
    String str = value.getString(runtime);
    std::string utf8 = str.utf8(runtime);
  } else if (value.isObject()) {
    Object obj = value.getObject(runtime);
  }
}
Object - JS 对象

创建对象

// 1. 空对象
Object obj(runtime);  // 等价于 JS: {}

// 2. 带原型的对象
Value proto = runtime.global().getProperty(runtime, "MyPrototype");
Object obj = Object::create(runtime, proto);

// 3. HostObject(C++ 对象)
class MyHostObject : public HostObject {
  Value get(Runtime& rt, const PropNameID& name) override {
    if (name.utf8(rt) == "value") {
      return Value(42);
    }
    return Value::undefined();
  }
};

auto ho = std::make_shared<MyHostObject>();
Object obj = Object::createFromHostObject(runtime, ho);

属性操作

Object obj(runtime);

// 设置属性(支持多种类型)
obj.setProperty(runtime, "name", "John");  // const char*
obj.setProperty(runtime, "age", 30);       // int
obj.setProperty(runtime, "active", true);  // bool

// 获取属性
Value name = obj.getProperty(runtime, "name");
if (name.isString()) {
  std::string str = name.getString(runtime).utf8(runtime);
}

// 检查属性
if (obj.hasProperty(runtime, "age")) {
  // 属性存在
}

// 删除属性
obj.deleteProperty(runtime, "age");
Function - JS 函数

创建 C++ 函数供 JS 调用

// 定义 C++ lambda函数
auto myFunc = [](Runtime& runtime,
                  const Value& thisVal,
                  const Value* args,
                  size_t count) -> Value {
  // 参数校验
  if (count < 2) {
    throw JSError(runtime, "Expected 2 arguments");
  }

  if (!args[0].isNumber() || !args[1].isNumber()) {
    throw JSError(runtime, "Arguments must be numbers");
  }

  // 执行逻辑
  double sum = args[0].getNumber() + args[1].getNumber();
  return Value(sum);
};

// 注册到全局对象
auto funcName = PropNameID::forAscii(runtime, "myAdd");
Function func = Function::createFromHostFunction(
    runtime, funcName, 2, myFunc);

runtime.global().setProperty(runtime, "myAdd", func);

// JS 中调用:
// const result = myAdd(10, 20);  // 30

从 C++ 调用 JS 函数

// 获取 JS 函数
Value callback = obj.getProperty(runtime, "onClick");
if (callback.isObject() && callback.getObject(runtime).isFunction(runtime)) {
  Function func = callback.getObject(runtime).getFunction(runtime);

  // 方式 1:无 this,传递参数
  Value result = func.call(runtime, Value(10), Value(20));

  // 方式 2:带 this 上下文
  Object thisObj(runtime);
  Value result2 = func.callWithThis(runtime, thisObj, Value(10));

  // 方式 3:作为构造函数调用
  Value instance = func.callAsConstructor(runtime, Value("arg1"));
}
Array - JS 数组
// 创建数组
Array arr = Array::createWithElements(runtime, 1, 2, "hello", true);

// 访问元素
size_t length = arr.size(runtime);
for (size_t i = 0; i < length; i++) {
  Value element = arr.getValueAtIndex(runtime, i);
}

// 修改元素
arr.setValueAtIndex(runtime, 0, Value(100));

// 转换为普通 Object
Object obj = arr.asObject(runtime);  // 类型安全转换

JS与C++的调用机制

HostObject

HostObject是一个非常重要的概念,它的作用就是将一个C++对象直接暴露给JS层使用。

更具体的说:

  • HostObject 是一个 C++ 类,完整的类是 facebook::jsi::HostObject
  • 你可以通过它把原生对象(例如图片、存储、数据库连接等)暴露给 JS
  • JS 访问它的属性和方法时,看起来就像在用普通的 JS 对象

如何使用HostObject

1.在 C++ 中定义 HostObject:
class NativeStorage : public facebook::jsi::HostObject {
public:
  int expirationTime = 60 * 60 * 24; // 默认 1 天

  // 读属性:nativeStorage.xxx
  jsi::Value get(jsi::Runtime& runtime, const jsi::PropNameID& name) override {
    auto prop = name.utf8(runtime);
    if (prop == "expirationTime") {
      return jsi::Value(expirationTime);
    }

    // 也可以在这里返回“方法”,例如 setObject / object(见后文)
    // 否则:
    return jsi::Value::undefined();
  }

  // 写属性:nativeStorage.xxx = ...
  void set(jsi::Runtime& runtime, const jsi::PropNameID& name, const jsi::Value& value) override {
    auto prop = name.utf8(runtime);
    if (prop == "expirationTime" && value.isNumber()) {
      expirationTime = (int)value.asNumber();
    }
  }
};

这里get方法相当于在定义:JS 中访问某个属性名时,底层到底要操作哪个 C++ 字段或执行什么逻辑

2.把 HostObject 实例挂到 JS 运行时
void NativeStorage::install(jsi::Runtime& runtime) {
  // 创建NativeStorage对象
  auto instance = std::make_shared<NativeStorage>();

  // 再从 HostObject 创建 JS 对象
  auto object = jsi::Object::createFromHostObject(runtime, instance);

  // 挂到 global 上,供 JS 使用:global.nativeStorage
  runtime.global().setProperty(runtime, "nativeStorage", object);
}

这个过程可以称为安装,在适当的时机(通常是 JS runtime 已经创建好之后),调用这个 install 就行。

3.在 JS 侧使用
// 属性读写:对应 C++ 中 get/set 覆写
nativeStorage.expirationTime = 1000;
console.log(nativeStorage.expirationTime);  // -> 1000

从 JS 视角看,这就是一个普通对象;从 C++ 视角看,它是一个持有原生资源和逻辑的类实例。

4.添加方法

HostObject 不只可以暴露数据属性,还可以在 get() 中给某个属性名返回一个 HostFunction,这样这个属性在 JS 中就是一个 可调用方法

例如在上面 NativeStorage 的基础上,给它加上 setObject / object 方法:

jsi::Value get(jsi::Runtime& runtime, const jsi::PropNameID& name) override {
  auto prop = name.utf8(runtime);

  if (prop == "expirationTime") {
    return jsi::Value(expirationTime);
  }

  if (prop == "setObject") {
    return jsi::Function::createFromHostFunction(
      runtime,
      jsi::PropNameID::forAscii(runtime, "setObject"),
      2, // 参数个数:key, value
      [](jsi::Runtime& rt, const jsi::Value& thisVal,
         const jsi::Value* args, size_t count) -> jsi::Value {
        // 这里做参数转换 + 原生存储逻辑
        // 比如用 NSUserDefaults 或 SharedPreferences 等
        return jsi::Value(true);
      }
    );
  }

  if (prop == "object") {
    return jsi::Function::createFromHostFunction(
      runtime,
      jsi::PropNameID::forAscii(runtime, "object"),
      1,
      [](jsi::Runtime& rt, const jsi::Value& thisVal,
         const jsi::Value* args, size_t count) -> jsi::Value {
        // 这里从原生存储中读取并返回
        // return jsi::String::createFromUtf8(rt, ...);
        return jsi::Value::undefined();
      }
    );
  }

  return jsi::Value::undefined();
}

那么在JS 层就可以这样使用:

nativeStorage.setObject('greeting', 'Hello JSI!');
const text = nativeStorage.object('greeting');

C++ 调用JS

调用 JS 函数,大概可以分两种情况,分别是回调函数和Promise 交互

回调函数
void registerClickHandler(Runtime& runtime) {
  // 获取 JS 回调
  Value onClickValue = runtime.global().getProperty(runtime, "onClick");

  if (!onClickValue.isObject()) {
    throw JSError(runtime, "onClick is not defined");
  }

  Object onClickObj = onClickValue.getObject(runtime);
  if (!onClickObj.isFunction(runtime)) {
    throw JSError(runtime, "onClick is not a function");
  }

  Function onClick = onClickObj.getFunction(runtime);

  // C++ 事件触发时调用
  // 必须在 JS 线程执行
  onClick.call(runtime, 
      String::createFromAscii(runtime, "button1"),
      Value(100),  // x
      Value(200)); // y
}
Promise 交互
Value createPromise(Runtime& runtime) {
  // 获取 Promise 构造函数
  Object promiseConstructor = runtime.global()
      .getPropertyAsObject(runtime, "Promise");

  // 创建 executor 函数
  auto executor = [](Runtime& rt, const Value&, const Value* args, size_t count) {
    Function resolve = args[0].getObject(rt).getFunction(rt);
    Function reject = args[1].getObject(rt).getFunction(rt);

    // 模拟异步操作
    std::thread([&rt, resolve = std::move(resolve)]() mutable {
      std::this_thread::sleep_for(std::chrono::seconds(1));

      // 实际需要 MessageQueue 调度
      resolve.call(rt, Value(42));
    }).detach();

    return Value::undefined();
  };

  Function executorFunc = Function::createFromHostFunction(
      runtime,
      PropNameID::forAscii(runtime, "executor"),
      2,
      executor);

  // 调用 new Promise(executor)
  return promiseConstructor.asFunction(runtime)
      .callAsConstructor(runtime, executorFunc);
}
访问 JS 对象属性
void opObject(Runtime& runtime, const Object& obj) {
  // 1. 获取所有属性名
  Array propNames = obj.getPropertyNames(runtime);
  size_t length = propNames.size(runtime);

  std::cout << "Object properties:" << std::endl;
  for (size_t i = 0; i < length; i++) {
    Value nameValue = propNames.getValueAtIndex(runtime, i);
    if (nameValue.isString()) {
      std::string name = nameValue.getString(runtime).utf8(runtime);

      // 2. 获取属性值
      Value propValue = obj.getProperty(runtime, name.c_str());

      // 3. 类型判断
      std::string type;
      if (propValue.isUndefined()) type = "undefined";
      else if (propValue.isNull()) type = "null";
      else if (propValue.isBool()) type = "boolean";
      else if (propValue.isNumber()) type = "number";
      else if (propValue.isString()) type = "string";
      else if (propValue.isObject()) type = "object";

      std::cout << "  " << name << ": " << type << std::endl;
    }
  }
}

线程安全

由于JSI并不是线程安全的,如果直接在子线程调用JSI的相关接口,会导致闪退。因此,在使用JSI时,线程安全问题十分重要,必须谨慎。

  • jsi::Runtime 的实现(Hermes/JSC/V8)其内部状态、GC、对象分配都不是线程安全的
  • 必须保证所有 JSI 操作在单一 JS 线程上串行执行
  • 跨线程访问会导致数据竞争、内存损坏、崩溃

我们来看一下JSI提供的解决方案,源码react-native/packages/react-native/ReactCommon/callinvoker/ReactCommon/CallInvoker.h

class CallInvoker {
 public:
  // 异步调度到 JS 线程(最常用)
  virtual void invokeAsync(std::function<void(jsi::Runtime&)>&& func) noexcept = 0;
  
  // 同步调用(阻塞当前线程直到 JS 线程执行完成)
  virtual void invokeSync(std::function<void(jsi::Runtime&)>&& func) = 0;
  
  virtual ~CallInvoker() = default;
};

也就是说,我们必须通过invokeAsync把执行相关JSI操作的闭包发送到JS线程执行。这里的invokeAsync可以从任意线程安全调用,不阻塞JS线程。注意,JS线程的阻塞,会直接导致UI的卡顿。

接下来,我们看一个结合Promise的JSI异步线程处理的完整示例:

#include <jsi/jsi.h>
#include <ReactCommon/CallInvoker.h>
#include <thread>
#include <chrono>

using namespace facebook::jsi;

class NetworkModule : public jsi::HostObject {
 private:
  std::shared_ptr<CallInvoker> jsInvoker_;

 public:
  NetworkModule(std::shared_ptr<CallInvoker> jsInvoker) 
      : jsInvoker_(std::move(jsInvoker)) {}

  Value get(Runtime& runtime, const PropNameID& name) override {
    auto methodName = name.utf8(runtime);
  
    if (methodName == "fetchAsync") {
      return unction::createFromHostFunction(
          runtime,
          name,
          1,
          [this](Runtime& rt, const Value&, const Value* args, size_t count) -> Value {
            std::string url = args[0].getString(rt).utf8(rt);
        
            // 1. 获取 Promise 构造函数
            Object promiseConstructor = rt.global().getPropertyAsObject(rt, "Promise");
        
            // 2. 创建 executor 函数
            auto executor = Function::createFromHostFunction(
                rt,
                PropNameID::forAscii(rt, "executor"),
                2,
                [this, url](Runtime& runtime, const Value&, const Value* args, size_t) -> Value {
                  // 3. 保存 resolve/reject(使用 shared_ptr 延长生命周期)
                  auto resolve = std::make_shared<Function>(
                      args[0].getObject(rt).getFunction(rt));
                  auto reject = std::make_shared<Function>(
                      args[1].getObject(rt).getFunction(rt));
              
                  // 4. 后台线程执行
                  std::thread([this, resolve, reject, url]() {
                    try {
                      // 模拟网络请求
                      std::this_thread::sleep_for(std::chrono::seconds(1));
                      std::string result = "Response from " + url;
                  
                      // 5. 调度到 JS 线程
                      jsInvoker_->invokeAsync([resolve, result](Runtime& rt) {
                        // createFromUtf8 这类JSI API必须在JS线程执行
                        resolve->call(rt, String::createFromUtf8(rt, result));
                      });
                    } catch (const std::exception& e) {
                      jsInvoker_->invokeAsync([reject, msg = std::string(e.what())](Runtime& rt) {
                        reject->call(rt, String::createFromUtf8(rt, msg));
                      });
                    }
                  }).detach();
              
                  return Value::undefined();
                });
        
            // 6. 返回 new Promise(executor)
            return promiseConstructor.asFunction(rt).callAsConstructor(rt, executor);
          });
    }
  
    return Value::undefined();
  }
};

这样,上层JS 调用fetchAsync方法时,就会得到一个Promise对象,直到底层的子线程执行完任务后,将结果返回,上层Promise才会返回结果。整个耗时操作都由底层C++线程完成,不会阻塞JS线程,在整个耗时任务期间,JS线程都可以继续执行其他任务。

一些工具类

关于Promise 辅助类的使用。可以查看头文件react-native/packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModuleUtils.h

namespace facebook::react {

struct Promise : public LongLivedObject {
  Promise(jsi::Runtime &rt, jsi::Function resolve, jsi::Function reject);

  void resolve(const jsi::Value &result);
  void reject(const std::string &message);

  jsi::Function resolve_;
  jsi::Function reject_;
};

using PromiseSetupFunctionType = std::function<void(jsi::Runtime &rt, std::shared_ptr<Promise>)>;
jsi::Value createPromiseAsJSIValue(jsi::Runtime &rt, PromiseSetupFunctionType &&func);

} 

使用示例:

// 在 HostFunction 中返回 Promise
return createPromiseAsJSIValue(rt, [jsInvoker](Runtime& rt, std::shared_ptr<Promise> promise) {
  // 异步操作
  std::thread([jsInvoker, promise]() {
    // 后台工作...
  
    // 完成后调度到 JS 线程
    jsInvoker->invokeAsync([promise](Runtime& rt) {
      promise->resolve(Value(42));  // 或 promise->reject("error")
    });
  }).detach();
});

LongLivedObject 可以防止过早的内存回收:

class MyData : public LongLivedObject {
 public:
  MyData(Runtime& rt) : LongLivedObject(rt) {}
  
  void done() {
    allowRelease();  // 允许被回收
  }
};

// 使用
auto data = std::make_shared<MyData>(runtime);
LongLivedObjectCollection::get(runtime).add(data);  // 防止回收
// ... 使用 data
data->allowRelease();  // 不需要时记得释放

常见错误示例

错误 1:直接在后台线程调用 JSI
// ❌ 错误
std::thread([&runtime, callback]() {
  callback.call(runtime, Value(42));  // CRASH!
}).detach();

// ✅ 正确
std::thread([jsInvoker, callback = std::move(callback)]() mutable {
  jsInvoker->invokeAsync([callback = std::move(callback)](Runtime& rt) {
    callback.call(rt, Value(42));
  });
}).detach();
错误 2:在异步回调中直接使用 runtime 引用
// ❌ 错误:runtime 引用可能失效
void asyncOp(Runtime& runtime, Function callback) {
  std::thread([&runtime, callback]() {  // 引用捕获危险
    jsInvoker->invokeAsync([&runtime, callback](Runtime&) {
      callback.call(runtime, Value(42));  // runtime 可能已销毁
    });
  }).detach();
}

// ✅ 正确:使用 lambda 传入的 runtime
void asyncOp(Runtime& runtime, Function callback, std::shared_ptr<CallInvoker> jsInvoker) {
  jsInvoker->invokeAsync([callback = std::move(callback)](Runtime& rt) {
    callback.call(rt, Value(42));  // 使用 lambda 参数rt
  });
}
错误 3:忘记 Promise 生命周期管理
// ❌ 错误:Promise 可能被释放
auto promise = std::make_shared<Promise>(rt, resolve, reject);
std::thread([promise]() {
  // Promise 可能被释放
}).detach();

// ✅ 正确:使用 createPromiseAsJSIValue(自动管理)
return createPromiseAsJSIValue(rt, [](Runtime& rt, std::shared_ptr<Promise> promise) {
  // promise 已自动加入 LongLivedObjectCollection
});

关于createPromiseAsJSIValue函数,前面已经演示过了。

错误处理

看一个完整JSI错误处理示例:

Value safeCall(Runtime& runtime, const Function& func, const Value* args, size_t count) {
  try {
    return func.call(runtime, args, count);
  } catch (const JSError& e) {
    // JS 异常
    std::cerr << "JS Error: " << e.getMessage() << std::endl;
    std::cerr << "Stack: " << e.getStack() << std::endl;
    throw;
  } catch (const JSINativeException& e) {
    // JSI 原生异常
    std::cerr << "Native Error: " << e.what() << std::endl;
    throw;
  } catch (const std::exception& e) {
    // 其他 C++ 异常
    std::cerr << "C++ Error: " << e.what() << std::endl;
    throw JSError(runtime, e.what());
  }
}

追踪详细的异常栈:

void executeWithStackTrace(Runtime& runtime, const std::string& code) {
  try {
    runtime.evaluateJavaScript(
        std::make_shared<StringBuffer>(code),
        "debug.js");
  } catch (const JSError& e) {
    std::cerr << "=== JavaScript Error ===" << std::endl;
    std::cerr << "Message: " << e.getMessage() << std::endl;
    std::cerr << "Stack:\n" << e.getStack() << std::endl;
  
    // 可以进一步解析堆栈
    std::istringstream stream(e.getStack());
    std::string line;
    int frameNum = 0;
    while (std::getline(stream, line)) {
      std::cerr << "  #" << frameNum++ << " " << line << std::endl;
    }
  }
}

从零实现 React Native(2): 跨平台支持

上一回:从零实现 React Native(1): 桥通信的原理与实现

平台支持的取舍

在上一篇《从零实现 React Native(1): 桥通信的原理与实现》中,基于 macos 平台实现了 JS 与 Native 的双向桥通信,在本篇中将对其他平台进行支持,实现「write once,run anywhere」这个理念。

接下来便来进行 iOS 和 Android 平台的支持工作。

iOS 进展顺利

在支持了 macos 端后,支持 iOS 是很容易的,可以马上着手来搞这个事情。得益于 Apple 生态带来的:macOS 和 iOS 都内置了 JavaScriptCore.framework,这意味着无需额外的引擎移植工作;且编程 API 很相似,这意味着差异化实现较少,大多可复用或类比实现。

事实上,我只花了半天时间就完成了 iOS 端的支持工作,其中主要的时间花在了构建配置的修改、测试示例的新增和调整,少部分时间花在了差异化的 DeviceInfo 模块实现。

得益于 Apple 生态,iOS 的支持工作中大部分代码都是复用的,复用率 90%。因为 macos 和 iOS 的 JSC API 一致,以及 C++ 语言的优势,可以用于跨端复用。复用的内容包含:

  • JSCExector
  • Bridge 通信逻辑
  • 模块注册逻辑

Android 滑铁卢

在顺利支持了 iOS 后,预想是 Android 的支持也不会太难,但实际做起来发现没这么简单。

记得是周末的午后的轻松下午,我先把 Android 的相关环境搭建好(包括 Android Studio、Java SDK 及其环境变量、NDK 等),然后进入 JSC 的移植工作。Why JSC 移植?因为不同于 Apple 生态,Android 系统是没有内置 JSC 引擎的。而正是这一步让我陷入泥潭。

我首先尝试了三方编译的版本,但是要么遇到了 libjsc.so(JSC 编译后的二进制文件,可供 Android 平台运行,可类比理解为前端的 wasm) 不支持 arm64(由于是 MBP 机器,安卓模拟器必须用 arm64 而非 x86 架构的),要么是遇到了 libjsc.so 和 NDK 版本不兼容。然后尝试了从社区提供的 jsc-android-buildscripts 自行编译,也遇到了各种问题编译失败,考虑到每次编译时间:2-3 小时,这也是一个艰难的旅程。

就算解决了 JavaScriptCore,还有 JNI 在等着我。Java 和 C++ 之间的桥梁不是简单的函数调用。我要处理诸如:类型转换、线程同步等问题。前方有很多新的坑在等着我。

舍与得

Maybe it's not the right time. 先理解核心,再扩展边界。先放下 Android 的支持,或许未来的某一天再回头来看这件事。

这个决定让我想起了 MVP(最小可行产品)的原则:先让核心功能跑通,再逐步完善。在学习项目中,这个原则同样适用——先掌握本质,再扩展边界。

既然决定专注于 iOS 和 macOS 双平台,那么接下来就需要一套优雅的构建系统来支撑跨平台开发。一个好的构建系统不仅能让开发者轻松切换平台,更重要的是,它能为后续的代码复用奠定基础。

构建系统的演进

在上一篇博客中,受制于篇幅的限制,跳过了对构建系统的讲解。而在跨平台支持中,天然需要迭代构建系统,也正是对其展开讲讲的一个好时机。

Make 是什么

Make 是一个诞生于 1976 年的构建工具,它的工作原理很简单:描述文件之间的依赖关系,然后只重新编译"变化过的"文件。

Make 适合于 需要多步骤构建流程 的项目,本项目的构建流程较为复杂:JS 产物打包 -> CMake 配置 -> C++ 产物编译 -> 运行 test 代码,很适合引入 Make 进行任务流程的编排。

Make 工具的配套 Makefile 文件是一个文本配置文件,它定义了构建规则、依赖关系和执行命令,可以将其理解为 npm 和 package.json 的关系。

以下是基于 macos 编译和测试的 Makefile 文件摘要代码,核心步骤包含了 js-build, configure, test

# ============================================
# 变量定义 (Makefile 第 10-13 行)
# ============================================
BUILD_DIR = build
CMAKE_BUILD_TYPE ?= Debug
CORES = $(shell sysctl -n hw.ncpu)  # 动态检测 CPU 核心数

# ============================================
# 主要构建目标 - 依赖链设计
# ============================================

# 默认目标:make 等价于 make build
.PHONY: all
all: build

# 核心构建流程:js-build → configure → 实际编译
.PHONY: build
build: js-build configure
    @echo "🔨 Building Mini React Native..."
    @cd $(BUILD_DIR) && make -j$(CORES)
    @echo "✅ Build complete"

# ============================================
# 步骤 1:JavaScript 构建 (第 29-33 行)
# ============================================
.PHONY: js-build
js-build:
    @echo "📦 Building JavaScript bundle..."
    @npm run build    # 执行 rollup -c,生成 dist/bundle.js
    @echo "✅ JavaScript bundle built"

# ============================================
# 步骤 2:CMake 配置 (第 22-26 行)
# ============================================
.PHONY: configure
configure:
    @echo "🔧 Configuring build system..."
    @mkdir -p $(BUILD_DIR)
    @cd $(BUILD_DIR) && cmake -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..
    @echo "✅ Configuration complete"


# ============================================
# 步骤 4:分层测试流程 (第 91-130 行)
# ============================================

# 完整测试:build → 4 个测试依次执行
.PHONY: test
test: build
    @echo "🧪 Running all tests..."
    @echo "\n📝 Test 1: Basic functionality test"
    @./$(BUILD_DIR)/mini_rn_test
    @echo "\n📝 Test 2: Module framework test"
    @./$(BUILD_DIR)/test_module_framework
    @echo "\n📝 Test 3: Integration test"
    @./$(BUILD_DIR)/test_integration
    @echo "\n📝 Test 4: Performance test"
    @./$(BUILD_DIR)/test_performance
    @echo "\n✅ All tests complete"

# 单独的测试目标 - 允许细粒度测试
.PHONY: test-basic
test-basic: build
    @echo "🧪 Running basic functionality test..."
    @./$(BUILD_DIR)/mini_rn_test

.PHONY: test-performance
test-performance: build
    @echo "🧪 Running performance test..."
    @./$(BUILD_DIR)/test_performance

在引入了 make 后,可以很方便的进行复杂流程的编排,例如我们想要运行测试代码时,实际的发生的事情如下所示。

用户命令: make test
    ↓
test: build
    ↓
build: js-build configure
        ↓             ↓
    js-build          configure
        ↓                   ↓
    npm run build       cmake ..
        ↓                   ↓
    dist/bundle.js      build/Makefile
                            ↓
                        make -j8 (CMake 管理的依赖)
                            ↓
                        libmini_react_native.a
                            ↓
                        mini_rn_test (等 4 个可执行文件)

Before 引入 Make:想象一下,如果没有 Make,每次修改代码后你需要手动执行

# 步骤1:构建 JavaScript
npm run build

# 步骤2:配置 CMake
mkdir -p build
cd build && cmake ..

# 步骤3:编译 C++
cd build && make -j8

# 步骤4:运行测试
./build/mini_rn_test
./build/test_module_framework
./build/test_integration
./build/test_performance

每次都要记住这么多命令,还要确保执行顺序正确。更糟糕的是,如果某个步骤失败了,你需要手动判断从哪里重新开始。

After 引入 Makemake test 一条命令搞定所有事情

CMake 是什么

在把 C++ 代码编译成二进制文件这一步之前,其实构建系统提前引入了 CMake 进行管理。CMake 不是“构建工具”,而是“构建系统的构建系统”,在这个场景中 CMake 实际上生成了编译代码的工具 Makefile 文件。CMake 会读取 CMakeLists.txt,然后生成原生的构建文件。

Why CMake?因为 mini-rn 项目开始之初就是要考虑多平台支持的,为了实现这个 feature,便会遇到 多平台构建的复杂性 这个问题。

问题 1:平台特定源文件管理

不同平台需要不同的实现:

  • macOS:使用 IOKit 获取硬件信息
  • iOS:使用 UIDevice 获取设备信息

没有 CMake 需要维护两套构建脚本,引入 CMake 后可通过条件编译一套配置搞定。

问题 2:系统框架动态链接

不同平台需要链接不同框架:macOS 需要 IOKit,iOS 需要 UIKit

引入 CMake 后可自动检测并链接正确的框架。

解决效果

引入 CMake 前:需要维护多套构建脚本,手动管理复杂配置,容易出错。

引入 CMake 后:一套 CMakeLists.txt 支持所有平台,自动处理平台差异,大幅降低维护成本。

CMake 关键语法解释

  • CMAKE_SYSTEM_NAME:CMake 内置变量,表示目标系统名称(iOS、Darwin等)
  • find_library():在系统中查找指定的库文件
  • target_link_libraries():将库文件链接到目标可执行文件
  • set():设置变量的值
  • if(MATCHES):条件判断,支持正则表达式匹配

改动一:Makefile 新增 iOS 构建目标

在 macOS 的可扩展构建系统配置就绪后,接下来看看如何改动以支持 iOS。

改动一实现了什么?

核心目标:在现有 Makefile 基础上,新增 iOS 平台的完整构建流程,实现"一套 Makefile,双平台支持"。

具体实现

  1. 新增 4 个 iOS 专用目标ios-configureios-buildios-testios-test-deviceinfo
  2. 建立 iOS 构建流程:js-build → ios-configure → ios-build → ios-test
  3. 实现平台隔离:iOS 使用独立的 build_ios/ 目录,与 macOS 的 build/ 目录完全分离
  4. 自动化 Xcode 环境配置:自动检测 SDK 路径、设置开发者目录、配置模拟器架构

新增的 4 个 iOS 目标

原本基于 macOS 的构建路径是:js-build → configure → build → test,现在为 iOS 新增了对应的平行路径:js-build → ios-configure → ios-build → ios-test。

# iOS 构建配置(模拟器)
.PHONY: ios-configure
ios-configure:
    @mkdir -p $(BUILD_DIR)_ios
    @cd $(BUILD_DIR)_ios && DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer cmake \
        -DCMAKE_SYSTEM_NAME=iOS \
        -DCMAKE_OSX_ARCHITECTURES=$$(uname -m) \
        -DCMAKE_OSX_SYSROOT=$$(DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun --sdk iphonesimulator --show-sdk-path) \
        -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \
        -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
        ..

# 构建 iOS 版本(模拟器)
.PHONY: ios-build
ios-build: js-build ios-configure
    @cd $(BUILD_DIR)_ios && make -j$(CORES)

# iOS 测试目标
.PHONY: ios-test
ios-test: ios-build
    @./test_ios.sh all

# iOS DeviceInfo 测试
.PHONY: ios-test-deviceinfo
ios-test-deviceinfo: ios-build
    @./test_ios.sh deviceinfo

关键设计决策

1. 独立构建目录

macOS 用 build/,iOS 用 build_ios/,互不干扰:

@mkdir -p $(BUILD_DIR)_ios   # iOS 构建目录

2. 仅支持 iOS 模拟器

为什么不支持真机?因为:

  • 真机需要开发者证书和配置文件
  • 模拟器足够验证 Bridge 通信机制
  • 降低环境配置复杂度
-DCMAKE_OSX_SYSROOT=$$(xcrun --sdk iphonesimulator --show-sdk-path)

3. 语义化命令

make ios-configure 比写一长串 CMake 命令简洁太多。这就是 Makefile 作为用户接口的价值。

改动二:CMake 平台条件编译

改动二实现了什么?

核心目标:让 CMake 能够智能识别目标平台,并自动选择正确的源文件和系统框架,实现"一套 CMakeLists.txt,智能适配双平台"。

具体实现

  1. 平台检测机制:通过 CMAKE_SYSTEM_NAME 变量自动识别是 macOS 还是 iOS
  2. 源文件智能选择:根据平台自动选择对应的 .mm 实现文件
  3. 框架动态链接:iOS 链接 UIKit,macOS 链接 IOKit,共享 JavaScriptCore 和 Foundation
  4. 编译标志自动设置:为 Objective-C++ 文件自动设置 ARC 标志
  5. 部署目标配置:iOS 设为 12.0+,macOS 设为 10.15+

设计精髓:编译时确定,运行时无开销。最终的 iOS 二进制文件中完全没有 macOS 代码,反之亦然。

原来的代码(仅 macOS)

# 原始版本 - 仅支持 macOS
if(APPLE)
    set(PLATFORM_SOURCES
        src/macos/modules/deviceinfo/DeviceInfoModule.mm
    )
    find_library(IOKIT_FRAMEWORK IOKit)
endif()

target_link_libraries(mini_react_native
    ${JAVASCRIPTCORE_FRAMEWORK}
    ${IOKIT_FRAMEWORK}
)

演进后的代码(macOS + iOS)

# 演进版本 - 支持 macOS + iOS
if(APPLE)
    # 根据具体平台选择源文件
    if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
        set(PLATFORM_SOURCES
            src/ios/modules/deviceinfo/DeviceInfoModule.mm
        )
    else()
        # macOS
        set(PLATFORM_SOURCES
            src/macos/modules/deviceinfo/DeviceInfoModule.mm
        )
    endif()

    # 平台特定框架
    if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
        find_library(UIKIT_FRAMEWORK UIKit)
        set(PLATFORM_FRAMEWORKS ${UIKIT_FRAMEWORK})
    else()
        find_library(IOKIT_FRAMEWORK IOKit)
        set(PLATFORM_FRAMEWORKS ${IOKIT_FRAMEWORK})
    endif()

    # 统一链接
    target_link_libraries(mini_react_native
        ${JAVASCRIPTCORE_FRAMEWORK}
        ${FOUNDATION_FRAMEWORK}
        ${PLATFORM_FRAMEWORKS}
    )
endif()

三个关键变化

1. 源文件分离

src/
├── macos/modules/deviceinfo/DeviceInfoModule.mm
└── ios/modules/deviceinfo/DeviceInfoModule.mm

两个文件虽然文件名相同,但实现不同:

  • macOS 版本:用 IOKit 获取硬件信息
  • iOS 版本:用 UIDevice 获取设备信息

2. 框架动态链接

平台 共享框架 平台特定框架
macOS JavaScriptCore, Foundation IOKit
iOS JavaScriptCore, Foundation UIKit

3. 部署目标设置

if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
    set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0")
elseif(${CMAKE_SYSTEM_NAME} MATCHES "Darwin")
    set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15")
endif()

两个改动的协同作用

改动一 + 改动二 = 完美的跨平台构建系统

这两个改动巧妙地分工合作:

  • Makefile(改动一):作为用户接口层,提供简单统一的命令,隐藏平台配置的复杂性
  • CMake(改动二):作为构建逻辑层,智能处理平台差异,自动选择正确的源文件和框架

协同效果

  1. 开发者体验make build vs make ios-build,命令接口一致
  2. 构建隔离:两个平台使用独立目录,可以并行构建,切换无需清理
  3. 智能适配:CMake 根据 Makefile 传入的平台信息,自动配置所有细节
  4. 零运行时开销:编译时就确定了平台,最终二进制文件纯净无冗余

这种设计让跨平台支持变得既强大又优雅:开发者只需要记住两个命令,背后的所有复杂性都被自动化处理了。

DeviceInfo - 变与不变

在构建系统演进完成后,我们来深入分析 DeviceInfo 模块的双平台实现。这个模块展示了跨平台架构设计的智慧:如何在保持接口统一的同时,让每个平台发挥自身优势。

90% 复用率是怎么做到的?

关键洞察:大部分逻辑其实是平台无关的

仔细分析 DeviceInfo 模块,你会发现一个惊人的事实:

// 这些逻辑在任何平台都一样
std::string DeviceInfoModule::getName() const {
    return "DeviceInfo";
}

std::vector<std::string> DeviceInfoModule::getMethods() const {
    return {
        "getUniqueId",       // methodId = 0
        "getSystemVersion",  // methodId = 1
        "getDeviceId"        // methodId = 2
    };
}

void DeviceInfoModule::invoke(const std::string& methodName,
                             const std::string& args, int callId) {
    try {
        if (methodName == "getUniqueId") {
            std::string uniqueId = getUniqueIdImpl();  // 只是调用,不关心具体实现
            sendSuccessCallback(callId, uniqueId);
        } else {
            sendErrorCallback(callId, "Unknown method: " + methodName);
        }
    } catch (const std::exception& e) {
        sendErrorCallback(callId, "Method invocation failed: " + std::string(e.what()));
    }
}

**Bridge 通信协议、方法注册机制、消息分发逻辑,完全都是可以复用的!**真正不同的,只是那几个 xxxImpl() 方法的底层实现。

复用的边界

但这里有个更深层的问题:为什么有些代码能 100% 复用,有些却完全不能?

让我们看看实际的复用率统计:

代码类型 复用率 为什么?
Bridge 通信逻辑 100% 协议标准化
模块注册机制 100% 框架层抽象
错误处理机制 100% 异常处理逻辑相同
设备唯一标识 0% 平台理念完全不同
系统版本获取 95% 只有注释不同
设备型号获取 85% 都用 sysctlbyname,iOS多了模拟器判断

100% 复用:协议的力量

为什么 Bridge 通信能 100% 复用?

因为这是协议层,不管底层平台怎么变,JavaScript 和 Native 之间的通信协议是固定的。方法名、参数、回调 ID、错误处理这些都是标准化的。就像 HTTP 协议,不管服务器是 Linux 还是 Windows,浏览器都用同样的方式发请求。

0% 复用:平台的鸿沟

为什么设备唯一标识完全不能复用?

macOS 追求真正的硬件级别唯一性,有复杂的降级机制;iOS 在 MVP 阶段采用了简化策略,每次启动生成新ID。这不是技术问题,而是:

  1. 平台哲学的差异:桌面 vs 移动的隐私理念
  2. 开发策略的差异:完整实现 vs MVP验证

复用边界的哲学

通过 DeviceInfo 模块,我们发现了跨平台复用的三个层次:

  1. 协议层:100% 复用,因为标准统一
  2. API 层:看运气,苹果生态有优势
  3. 实现层:看平台差异,移动端更复杂

这揭示了一个残酷的真相:跨平台的成本永远存在,只是被转移了。

可以用抽象基类隐藏差异,但差异本身不会消失。关键是找到合适的边界,让复用最大化,让差异最小化。

头文件的魔法

解决方案其实就是基于 面向对象 的:

// common/modules/DeviceInfoModule.h
class DeviceInfoModule : public NativeModule {
public:
    DeviceInfoModule();
    ~DeviceInfoModule() override = default;

    // NativeModule 接口实现 - 所有平台共享
    std::string getName() const override;
    std::vector<std::string> getMethods() const override;
    void invoke(const std::string& methodName, const std::string& args,
                int callId) override;

    // 平台特定的实现接口 - 让各平台去填这些"洞"
    std::string getUniqueIdImpl() const;
    std::string getSystemVersionImpl() const;
    std::string getDeviceIdImpl() const;

private:
    // 工具方法
    std::string createSuccessResponse(const std::string& data) const;
    std::string createErrorResponse(const std::string& error) const;
};

注意这里没有用虚函数,因为已经引入了 CMake 在编译时确定了对应平台的文件,不需要运行时多态,结果是 同一个头文件,不同的实现文件。每个平台都有自己的 .mm 文件来实现这些方法,编译时自动选择对应的实现。

基类定义了 what(做什么),各平台实现 how (怎么做)。Bridge 通信、方法注册、消息分发等这些复杂的逻辑只写一遍,所有平台自动继承。

分平台实现

// macOS 实现 - src/macos/modules/deviceinfo/DeviceInfoModule.mm
std::string DeviceInfoModule::getUniqueIdImpl() const {
    @autoreleasepool {
        // 尝试获取硬件 UUID
        io_registry_entry_t ioRegistryRoot =
            IORegistryEntryFromPath(kIOMasterPortDefault, "IOService:/");
        CFStringRef uuidCf = (CFStringRef)IORegistryEntryCreateCFProperty(
            ioRegistryRoot, CFSTR(kIOPlatformUUIDKey), kCFAllocatorDefault, 0);

        if (uuidCf) {
            NSString* uuid = (__bridge NSString*)uuidCf;
            std::string result = [uuid UTF8String];
            CFRelease(uuidCf);
            return result;
        }
        // 多层降级机制...
        return "macOS-" + getDeviceIdImpl() + "-" + getSystemVersionImpl();
    }
}

// iOS 实现 - src/ios/modules/deviceinfo/DeviceInfoModule.mm
std::string DeviceInfoModule::getUniqueIdImpl() const {
    @autoreleasepool {
        // iOS 简化实现:使用 NSUUID 生成唯一标识
        // 注意:这个实现每次启动都会生成新的ID,适用于MVP测试
        NSUUID* uuid = [NSUUID UUID];
        NSString* uuidString = [uuid UUIDString];
        return [uuidString UTF8String];
    }
}

Objective-C++ 关键字解释

  • @autoreleasepool:自动释放池,管理 Objective-C 对象的内存,确保及时释放
  • __bridge:ARC(自动引用计数)中的桥接转换,在 C/C++ 指针和 Objective-C 对象间转换
  • [object method]:Objective-C 的方法调用语法
  • .mm 文件扩展名:表示 Objective-C++ 文件,可以混合使用 C++、C 和 Objective-C 代码

两个平台的实现文件自动拥有了完整的 Bridge 通信能力,现在只需要实现平台差异部分即可~

应自动化尽自动化

DeviceInfo 模块的自动化实现揭示了一个重要原则:

好的跨平台架构不是让代码在所有平台都能跑,而是让正确的代码在正确的平台上跑。

通过这个项目的三层自动化体系:

  1. Makefile 自动化:统一的命令接口,隐藏平台配置复杂性
  2. CMake 自动化:智能的源文件选择和框架链接
  3. 编译器自动化:平台特定的二进制生成

这样的架构让开发者专注于业务逻辑,而把平台适配的复杂性交给了工具链。

真正的自动化不是写一份代码到处跑,而是:

  • 开发体验统一make build vs make ios-build,命令接口一致
  • 实现策略分离:每个平台有最适合的实现方式
  • 构建过程透明:开发者不需要关心 Xcode SDK 路径、编译标志等细节

这种设计在面对更复杂的系统时依然有效:只要保持接口统一、实现分离、构建自动化,就能优雅地扩展到视图渲染、事件处理等更复杂的场景。

彩蛋

项目地址: github.com/zerosrat/mi…

当前项目中包含了本篇文章中的全部内容:

  • ✅ iOS 构建系统适配
  • ✅ iOS 跨平台的差异化实现(DeviceInfo)

完成本阶段后,项目已经具备了进入第三阶段的基础:视图渲染系统


📝 本文首发于个人博客: zerosrat.dev/n/2025/mini…

Zustand 入门:React Native 状态管理的正确用法

一、Zustand 是什么,适合什么场景

Zustand 是一个轻量级、基于 hooks 的状态管理库,核心特点是:

  • 无 Provider(无需 Context 包裹)
  • API 极简(create + hooks)
  • 按需订阅(避免无关组件重渲染)
  • 对 React Native 友好(无额外平台依赖)
  • 可渐进式引入

非常适合以下 RN 场景:

  • 中小规模应用
  • RN Hybrid / Module 化工程
  • UI 状态 + 业务状态混合管理
  • 替代部分 Redux 的场景

二、安装

yarn add zustand
# 或
npm install zustand

React Native 无需额外配置。


三、最基础用法(核心必会)

1. 创建 Store

// store/useCounterStore.ts
import { create } from 'zustand';

type CounterState = {
  count: number;
  inc: () => void;
  dec: () => void;
};

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  inc: () => set((state) => ({ count: state.count + 1 })),
  dec: () => set((state) => ({ count: state.count - 1 })),
}));

2. 在组件中使用

import React from 'react';
import { View, Text, Button } from 'react-native';
import { useCounterStore } from './store/useCounterStore';

export default function Counter() {
  const count = useCounterStore((state) => state.count);
  const inc = useCounterStore((state) => state.inc);

  return (
    <View>
      <Text>Count: {count}</Text>
      <Button title="+" onPress={inc} />
    </View>
  );
}

关键点

  • selector 模式useStore(state => state.xxx)
  • 只订阅使用到的字段,避免全量刷新

四、推荐的工程化写法(重要)

❌ 不推荐

const store = useStore();

这样会导致任意状态变更都触发重渲染


✅ 推荐:拆分 selector

const count = useCounterStore((s) => s.count);
const inc = useCounterStore((s) => s.inc);

或:

const { count, inc } = useCounterStore(
  (s) => ({ count: s.count, inc: s.inc })
);

五、Zustand 在 React Native 中的常见模式

1. 全局 UI 状态(Loading / Modal)

type UIState = {
  loading: boolean;
  showLoading: () => void;
  hideLoading: () => void;
};

export const useUIStore = create<UIState>((set) => ({
  loading: false,
  showLoading: () => set({ loading: true }),
  hideLoading: () => set({ loading: false }),
}));
const loading = useUIStore((s) => s.loading);

2. 业务状态(登录信息)

type User = {
  id: string;
  name: string;
};

type AuthState = {
  user?: User;
  login: (u: User) => void;
  logout: () => void;
};

export const useAuthStore = create<AuthState>((set) => ({
  user: undefined,
  login: (user) => set({ user }),
  logout: () => set({ user: undefined }),
}));

3. 异步 Action(非常自然)

type ListState = {
  list: string[];
  loading: boolean;
  fetchList: () => Promise<void>;
};

export const useListStore = create<ListState>((set) => ({
  list: [],
  loading: false,
  fetchList: async () => {
    set({ loading: true });
    const res = await fetch('https://example.com/list');
    const data = await res.json();
    set({ list: data, loading: false });
  },
}));

RN 中无需 thunk / saga。


六、性能优化(RN 场景非常关键)

1. 使用 shallow 避免对象对比

import { shallow } from 'zustand/shallow';

const { count, inc } = useCounterStore(
  (s) => ({ count: s.count, inc: s.inc }),
  shallow
);

2. 将高频 UI 状态拆分 Store

store/
 ├── useAuthStore.ts
 ├── useUIStore.ts
 ├── useListStore.ts

避免一个大 Store。


七、持久化(AsyncStorage)

RN 常用:zustand + persist

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

export const useAuthStore = create(
  persist(
    (set) => ({
      token: '',
      setToken: (token: string) => set({ token }),
      clearToken: () => set({ token: '' }),
    }),
    {
      name: 'auth-storage',
      storage: {
        getItem: AsyncStorage.getItem,
        setItem: AsyncStorage.setItem,
        removeItem: AsyncStorage.removeItem,
      },
    }
  )
);

八、Zustand vs Redux Toolkit(RN 实战视角)

维度 Zustand Redux Toolkit
学习成本 极低
样板代码 极少
Provider 不需要 必须
异步 原生支持 thunk / saga
DevTools
大型团队 一般 更适合

个人建议

  • RN 业务页面、模块级状态:Zustand
  • 复杂全局状态、多人协作:RTK
  • 二者可以共存

九、常见坑位总结

  1. 不要整 store 订阅
  2. 不要把所有状态塞进一个 store
  3. RN 中慎用大对象(列表分页要拆分)
  4. persist + AsyncStorage 要注意冷启动恢复时机

❌