深入理解VideoToolbox:iOS/macOS视频硬编解码实战指南

引言:VideoToolbox框架概述

VideoToolbox 是Apple提供的底层框架,首次在WWDC2014上推出,为iOS和macOS开发者提供直接访问硬件编码器和解码器的能力。作为Core Media框架的重要组成部分,VideoToolbox专注于视频压缩、解压缩以及CoreVideo像素缓冲区之间的格式转换,以Core Foundation (CF)类型的会话对象形式提供服务。

与AVFoundation等高层框架不同,VideoToolbox专为需要直接硬件访问的场景设计,适用于对性能要求严苛的应用,如实时视频通信、专业视频编辑和高分辨率媒体处理。对于不需要直接硬件控制的应用,Apple建议使用AVFoundation等更高级的框架。

支持平台与系统要求

VideoToolbox支持以下Apple平台:

  • iOS 8.0+:iPhone、iPad和iPod touch设备
  • macOS 10.8+:Macintosh计算机
  • tvOS 10.2+:Apple TV设备
  • visionOS 1.0+:Apple Vision Pro

框架采用硬件加速设计,充分利用Apple芯片中的媒体引擎,包括A系列芯片中的专用编解码模块和Apple Silicon的媒体处理单元(MPU),实现高效的视频处理。

核心架构与功能组件

框架核心组件

VideoToolbox提供三种主要会话类型,构成其核心架构:

  1. VTCompressionSession:视频编码会话,负责将原始视频数据压缩为H.264/HEVC等格式
  2. VTDecompressionSession:视频解码会话,负责将压缩视频数据解码为原始像素缓冲区
  3. VTPixelTransferSession:像素转换会话,处理不同像素格式之间的转换

这些会话对象通过属性键值对进行配置,支持细粒度的参数调整,以满足不同应用场景需求。

支持的编解码格式

VideoToolbox支持多种视频编解码格式:

编码支持

  • H.264/AVC (所有支持平台)
  • HEVC/H.265 (iOS 11+/macOS 10.13+)
  • ProRes (macOS)

解码支持

  • H.263、H.264、HEVC
  • MPEG-1、MPEG-2、MPEG-4 Part 2
  • ProRes、ProRes Raw
  • AV1 (部分设备)

硬件加速原理

VideoToolbox的硬件加速能力源于其直接访问Apple设备专用硬件编码器/解码器的能力:

  1. 专用硬件模块:Apple芯片包含专用的媒体处理单元,独立于CPU和GPU运作
  2. 低功耗设计:硬件编解码比软件实现减少70-80%的功耗
  3. 零拷贝优化:支持直接在GPU内存和编解码器之间传输数据,减少CPU干预
  4. 并行处理:硬件编码器可与CPU并行工作,提高整体系统性能

视频编码流程详解

编码基本流程

使用VTCompressionSession进行视频编码的核心步骤:

  1. 创建压缩会话:使用VTCompressionSessionCreate函数初始化
  2. 配置会话属性:设置码率、帧率、分辨率等编码参数
  3. 输入视频帧:通过VTCompressionSessionEncodeFrame输入CVPixelBuffer
  4. 处理编码结果:在回调函数中接收编码后的CMSampleBuffer
  5. 结束编码会话:调用VTCompressionSessionCompleteFrames和VTCompressionSessionInvalidate

创建压缩会话

objc 复制代码
static void EncodeCallBack(void *outputCallbackRefCon, void *sourceFrameRefCon, 
                          OSStatus status, VTEncodeInfoFlags infoFlags, CMSampleBufferRef sampleBuffer) {
    // 处理编码后的样本缓冲区
    if (status == noErr && sampleBuffer) {
        // 编码成功,处理输出数据
        NSLog(@"编码成功,样本缓冲区大小: %zd", CMSampleBufferGetTotalSampleSize(sampleBuffer));
        // 在这里可以将编码数据写入文件或发送网络
    } else {
        NSLog(@"编码失败,状态码: %d", (int)status);
    }
}

- (void)createCompressionSession {
    int width = 1920;
    int height = 1080;
    CMVideoCodecType codecType = kCMVideoCodecType_H264;
    
    OSStatus status = VTCompressionSessionCreate(
        NULL,                  // 分配器
        width,                 // 宽度
        height,                // 高度
        codecType,             // 编解码器类型
        NULL,                  // 编码器规格
        NULL,                  // 源图像缓冲区属性
        NULL,                  // 压缩数据分配器
        EncodeCallBack,        // 输出回调函数
        (__bridge void *)self, // 回调引用
        &_compressionSession   // 会话输出
    );
    
    if (status != noErr) {
        NSLog(@"创建压缩会话失败,状态码: %d", (int)status);
        return;
    }
    
    // 配置实时编码属性
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_RealTime, kCFBooleanTrue);
    
    // 准备编码
    VTCompressionSessionPrepareToEncodeFrames(_compressionSession);
}

配置编码参数

VideoToolbox提供丰富的编码参数配置选项,以下是常用属性设置:

objc 复制代码
- (void)configureCompressionProperties {
    // 设置码率控制模式为ABR(平均比特率)
    int averageBitRate = 5000000; // 5Mbps
    CFNumberRef bitRateRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &averageBitRate);
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_AverageBitRate, bitRateRef);
    CFRelease(bitRateRef);
    
    // 设置帧率
    int frameRate = 30;
    CFNumberRef frameRateRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &frameRate);
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_ExpectedFrameRate, frameRateRef);
    CFRelease(frameRateRef);
    
    // 设置关键帧间隔(GOP大小)
    int maxKeyFrameInterval = frameRate * 2; // 2秒一个关键帧
    CFNumberRef keyFrameIntervalRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &maxKeyFrameInterval);
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_MaxKeyFrameInterval, keyFrameIntervalRef);
    CFRelease(keyFrameIntervalRef);
    
    // 设置H.264 Profile Level
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_High_AutoLevel);
    
    // 禁用B帧(减少延迟)
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse);
}

编码视频帧

将从摄像头或其他源获取的CVPixelBuffer输入到编码会话:

objc 复制代码
- (void)encodePixelBuffer:(CVPixelBufferRef)pixelBuffer presentationTime:(CMTime)presentationTime {
    if (!_compressionSession) {
        NSLog(@"压缩会话未初始化");
        return;
    }
    
    // 设置帧时间戳
    VTEncodeInfoFlags flags = 0;
    OSStatus status = VTCompressionSessionEncodeFrame(
        _compressionSession,
        pixelBuffer,
        presentationTime,
        kCMTimeInvalid, // 持续时间
        NULL,           // 编码选项
        NULL,           // 源帧引用
        &flags          // 编码信息标志
    );
    
    if (status != noErr) {
        NSLog(@"编码帧失败,状态码: %d", (int)status);
        // 处理编码失败,可能需要重新创建会话
    }
}

视频解码流程详解

解码基本流程

使用VTDecompressionSession进行视频解码的核心步骤:

  1. 创建格式描述:从SPS/PPS或编码数据创建CMVideoFormatDescription
  2. 创建解压缩会话:使用VTDecompressionSessionCreate函数初始化
  3. 配置解码参数:设置像素格式、解码模式等
  4. 输入编码数据:通过VTDecompressionSessionDecodeFrame输入编码数据
  5. 处理解码结果:在回调中接收解码后的CVPixelBuffer
  6. 释放解码会话:调用VTDecompressionSessionInvalidate释放资源

创建解压缩会话

objc 复制代码
static void DecodeCallBack(void *decompressionOutputRefCon, void *sourceFrameRefCon, 
                          OSStatus status, VTDecodeInfoFlags infoFlags, 
                          CVImageBufferRef imageBuffer, CMTime presentationTime, 
                          CMTime presentationDuration) {
    // 处理解码后的图像缓冲区
    if (status == noErr && imageBuffer) {
        // 解码成功,处理图像数据
        NSLog(@"解码成功,图像尺寸: %dx%d", 
              CVPixelBufferGetWidth(imageBuffer), 
              CVPixelBufferGetHeight(imageBuffer));
        // 在这里可以将图像显示到屏幕或进行后续处理
    } else {
        NSLog(@"解码失败,状态码: %d", (int)status);
    }
}

- (void)createDecompressionSessionWithFormatDescription:(CMVideoFormatDescriptionRef)formatDescription {
    // 设置输出像素缓冲区属性
    NSDictionary *destinationImageBufferAttributes = @{
        (id)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA),
        (id)kCVPixelBufferWidthKey : @(CVPixelBufferGetWidth(imageBuffer)),
        (id)kCVPixelBufferHeightKey : @(CVPixelBufferGetHeight(imageBuffer)),
        (id)kCVPixelBufferIOSurfacePropertiesKey : @{}
    };
    
    OSStatus status = VTDecompressionSessionCreate(
        NULL,                          // 分配器
        formatDescription,             // 视频格式描述
        NULL,                          // 解码器规格
        (__bridge CFDictionaryRef)destinationImageBufferAttributes, // 目标图像属性
        &_decompressionSession         // 会话输出
    );
    
    if (status != noErr) {
        NSLog(@"创建解压缩会话失败,状态码: %d", (int)status);
        return;
    }
    
    // 设置解码回调
    VTDecompressionSessionSetOutputCallback(
        _decompressionSession,
        DecodeCallBack,
        (__bridge void *)self,
        NULL
    );
}

处理H.264码流格式

VideoToolbox仅支持AVCC/HVCC格式的码流,需要将Annex-B格式转换为AVCC格式:

objc 复制代码
- (CMSampleBufferRef)sampleBufferFromH264Data:(NSData *)h264Data 
                                 formatDescription:(CMVideoFormatDescriptionRef *)formatDescription {
    const uint8_t *bytes = [h264Data bytes];
    size_t length = [h264Data length];
    
    // 查找NALU起始码
    NSMutableArray *naluArray = [NSMutableArray array];
    size_t start = 0;
    for (size_t i = 2; i < length; i++) {
        if (bytes[i] == 0x01 && bytes[i-1] == 0x00 && bytes[i-2] == 0x00) {
            size_t naluSize = i - start - 3;
            if (naluSize > 0) {
                [naluArray addObject:[NSData dataWithBytes:bytes+start+3 length:naluSize]];
            }
            start = i + 1;
        }
    }
    
    // 处理SPS和PPS创建格式描述
    if (*formatDescription == NULL) {
        for (NSData *naluData in naluArray) {
            const uint8_t *naluBytes = [naluData bytes];
            uint8_t naluType = naluBytes[0] & 0x1F;
            
            if (naluType == 7) { // SPS
                _spsData = naluData;
            } else if (naluType == 8) { // PPS
                _ppsData = naluData;
                
                if (_spsData && _ppsData) {
                    const uint8_t *sps = [_spsData bytes];
                    const uint8_t *pps = [_ppsData bytes];
                    int spsSize = (int)[_spsData length];
                    int ppsSize = (int)[_ppsData length];
                    
                    // 创建格式描述
                    OSStatus status = CMVideoFormatDescriptionCreateFromH264ParameterSets(
                        kCFAllocatorDefault,
                        1,
                        &sps, &spsSize,
                        &pps, &ppsSize,
                        4, // NALU长度字段大小
                        formatDescription
                    );
                    
                    if (status != noErr) {
                        NSLog(@"创建格式描述失败,状态码: %d", (int)status);
                    }
                }
            }
        }
    }
    
    // 创建CMBlockBuffer和CMSampleBuffer
    if (*formatDescription) {
        CMBlockBufferRef blockBuffer = NULL;
        OSStatus status = CMBlockBufferCreateWithMemoryBlock(
            kCFAllocatorDefault,
            (void *)bytes,
            length,
            kCFAllocatorNull,
            NULL,
            0,
            length,
            0,
            &blockBuffer
        );
        
        if (status != noErr) {
            NSLog(@"创建BlockBuffer失败,状态码: %d", (int)status);
            return NULL;
        }
        
        CMSampleBufferRef sampleBuffer = NULL;
        const size_t sampleSize = length;
        status = CMSampleBufferCreateReady(
            kCFAllocatorDefault,
            blockBuffer,
            *formatDescription,
            1,
            0,
            NULL,
            1,
            &sampleSize,
            &sampleBuffer
        );
        
        if (status != noErr) {
            NSLog(@"创建SampleBuffer失败,状态码: %d", (int)status);
            CFRelease(blockBuffer);
            return NULL;
        }
        
        return sampleBuffer;
    }
    
    return NULL;
}

解码视频数据

将编码数据输入到解压缩会话进行解码:

objc 复制代码
- (void)decodeH264Data:(NSData *)h264Data {
    CMVideoFormatDescriptionRef formatDescription = NULL;
    CMSampleBufferRef sampleBuffer = [self sampleBufferFromH264Data:h264Data 
                                              formatDescription:&formatDescription];
    
    if (!sampleBuffer) {
        NSLog(@"无法创建SampleBuffer");
        return;
    }
    
    if (!_decompressionSession && formatDescription) {
        [self createDecompressionSessionWithFormatDescription:formatDescription];
    }
    
    if (_decompressionSession) {
        VTDecodeFrameFlags flags = kVTDecodeFrame_EnableAsynchronousDecompression;
        VTDecodeInfoFlags infoFlags = 0;
        
        OSStatus status = VTDecompressionSessionDecodeFrame(
            _decompressionSession,
            sampleBuffer,
            flags,
            NULL, // 源帧引用
            &infoFlags
        );
        
        if (status != noErr) {
            NSLog(@"解码帧失败,状态码: %d", (int)status);
        }
    }
    
    CFRelease(sampleBuffer);
    if (formatDescription) CFRelease(formatDescription);
}

高级应用与性能优化

低延迟编码配置

对于实时视频通信场景,配置低延迟编码模式:

objc 复制代码
- (void)configureLowLatencyEncoding {
    // 创建低延迟编码器规格
    CFDictionaryRef encoderSpecification = @{
        (id)kVTVideoEncoderSpecification_EnableLowLatencyRateControl: @YES
    };
    
    // 使用低延迟规格创建会话
    OSStatus status = VTCompressionSessionCreate(
        NULL,
        _width,
        _height,
        kCMVideoCodecType_H264,
        encoderSpecification, // 使用低延迟规格
        NULL,
        NULL,
        EncodeCallBack,
        (__bridge void *)self,
        &_compressionSession
    );
    
    if (status != noErr) {
        NSLog(@"创建低延迟压缩会话失败,状态码: %d", (int)status);
        return;
    }
    
    // 禁用B帧(低延迟关键配置)
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse);
    
    // 设置最大帧延迟为1(最小化延迟)
    int maxFrameDelay = 1;
    CFNumberRef maxFrameDelayRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &maxFrameDelay);
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_MaxFrameDelayCount, maxFrameDelayRef);
    CFRelease(maxFrameDelayRef);
    
    // 使用Constrained Baseline Profile提高兼容性
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_ConstrainedBaseline_AutoLevel);
}

性能对比与优化策略

VideoToolbox与其他编码方案的性能对比:

编码方案 速度 质量 CPU占用 功耗 适用场景
VideoToolbox硬件编码 快(5-10x) 中等 低(25-30%) 实时通信、直播
libx264软件编码 高(100%) 高质量视频制作
FFmpeg+VAAPI 中(3-5x) 中高 中(50-60%) 跨平台桌面应用

优化建议

  1. 码率控制

    • 实时场景使用ABR模式,设置合理的最小/最大码率
    • 存储场景可使用CQ模式,通过-q:v参数控制质量
  2. 线程管理

    • 使用专用串行队列处理编解码操作
    • 避免在回调中执行耗时操作
  3. 内存优化

    • 复用CVPixelBuffer对象,减少内存分配
    • 监控内存使用,避免在扩展中使用VTPixelRotationSession
  4. 错误恢复

    • 实现会话重建机制,处理编解码失败
    • 使用长期参考帧(LTR)提高丢包恢复能力

实际应用案例

案例1:实时视频会议应用

使用VideoToolbox实现低延迟视频编码:

objc 复制代码
// 配置低延迟参数
[self configureLowLatencyEncoding];

// 设置目标码率为1-2Mbps(适合视频会议)
int averageBitRate = 1500000; // 1.5Mbps
CFNumberRef bitRateRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &averageBitRate);
VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_AverageBitRate, bitRateRef);
CFRelease(bitRateRef);

// 设置较小的GOP大小(1秒)
int frameRate = 30;
int maxKeyFrameInterval = frameRate * 1; // 1秒一个关键帧
CFNumberRef keyFrameIntervalRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &maxKeyFrameInterval);
VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_MaxKeyFrameInterval, keyFrameIntervalRef);
CFRelease(keyFrameIntervalRef);

案例2:4K视频录制应用

优化高分辨率视频编码性能:

objc 复制代码
// 配置4K编码参数
int width = 3840;
int height = 2160;
CMVideoCodecType codecType = kCMVideoCodecType_HEVC; // 使用HEVC提高压缩效率

// 创建压缩会话
OSStatus status = VTCompressionSessionCreate(NULL, width, height, codecType, NULL, NULL, NULL, EncodeCallBack, (__bridge void *)self, &_compressionSession);

// 设置高码率(4K建议20-30Mbps)
int averageBitRate = 25000000; // 25Mbps
CFNumberRef bitRateRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &averageBitRate);
VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_AverageBitRate, bitRateRef);
CFRelease(bitRateRef);

// 启用硬件加速优先模式
CFDictionaryRef encoderSpecification = @{
    (id)kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder: @YES
};

常见问题与解决方案

问题1:编码会话创建失败

可能原因

  • 不支持的编解码器类型
  • 分辨率超出硬件限制
  • 设备不支持特定功能

解决方案

objc 复制代码
- (BOOL)createCompressionSessionWithCodecType:(CMVideoCodecType)codecType {
    // 检查硬件支持情况
    BOOL isSupported = NO;
    if (codecType == kCMVideoCodecType_HEVC) {
        if (@available(iOS 11.0, *)) {
            isSupported = VTIsHardwareDecodeSupported(codecType);
        } else {
            isSupported = NO;
        }
    } else {
        isSupported = VTIsHardwareDecodeSupported(codecType);
    }
    
    if (!isSupported) {
        NSLog(@"当前设备不支持%@硬件编码", codecType == kCMVideoCodecType_HEVC ? @"HEVC" : @"H.264");
        // 降级为支持的编解码器
        codecType = kCMVideoCodecType_H264;
    }
    
    // 检查分辨率限制
    CGSize maxResolution = [self maxSupportedResolutionForCodec:codecType];
    if (_width > maxResolution.width || _height > maxResolution.height) {
        NSLog(@"分辨率超出硬件限制,调整为%@", NSStringFromCGSize(maxResolution));
        _width = maxResolution.width;
        _height = maxResolution.height;
    }
    
    // 创建会话...
    return YES;
}

问题2:编码质量不佳

可能原因

  • 码率设置过低
  • Profile Level设置不当
  • 未启用CABAC熵编码

解决方案

objc 复制代码
// 提高编码质量的配置
- (void)improveEncodingQuality {
    // 提高目标码率
    int averageBitRate = 8000000; // 8Mbps for 1080p
    CFNumberRef bitRateRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &averageBitRate);
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_AverageBitRate, bitRateRef);
    CFRelease(bitRateRef);
    
    // 使用High Profile
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_High_AutoLevel);
    
    // 启用CABAC熵编码
    if ([self isSupportPropertyWithSession:_compressionSession key:kVTCompressionPropertyKey_H264EntropyMode]) {
        VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_H264EntropyMode, kVTH264EntropyMode_CABAC);
    }
    
    // 设置最大QP值,限制质量下限
    int maxQP = 35; // 数值越小质量越高,范围0-51
    CFNumberRef maxQPRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &maxQP);
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_MaxAllowedFrameQP, maxQPRef);
    CFRelease(maxQPRef);
}

问题3:解码画面闪烁或花屏

可能原因

  • NALU格式不正确
  • SPS/PPS参数集缺失或错误
  • 时间戳不连续

解决方案

objc 复制代码
// 确保正确处理SPS/PPS
- (void)handleParameterSets {
    // 在每个IDR帧前发送SPS/PPS
    if (naluType == 5) { // IDR帧
        if (_spsData && _ppsData) {
            [self sendNALU:_spsData]; // 发送SPS
            [self sendNALU:_ppsData]; // 发送PPS
        }
    }
    
    // 验证时间戳连续性
    if (CMTIME_IS_VALID(_lastPresentationTime) && 
        CMTimeCompare(presentationTime, _lastPresentationTime) <= 0) {
        NSLog(@"时间戳不连续,校正时间戳");
        presentationTime = CMTimeAdd(_lastPresentationTime, CMTimeMake(1, 30)); // 假设30fps
    }
    _lastPresentationTime = presentationTime;
}

总结与展望

VideoToolbox框架为Apple平台提供了强大的硬件加速视频编解码能力,是开发高性能视频应用的关键技术。通过直接访问硬件编码器/解码器,VideoToolbox能够在保持低CPU占用和低功耗的同时,提供高效的视频处理能力。

主要优势

  • 卓越的性能:硬件加速比软件编码快5-10倍
  • 低功耗设计:延长移动设备电池寿命
  • 紧密集成:与Core Media和AVFoundation无缝协作
  • 持续演进:支持最新的编解码标准和硬件特性

未来发展方向

  • AV1编解码支持的进一步完善
  • 空间视频编码的增强(Vision Pro生态)
  • AI辅助编码优化
  • 更精细的码率控制和质量优化

对于需要处理视频的iOS/macOS开发者,掌握VideoToolbox框架将为应用带来显著的性能提升和用户体验改善。通过合理配置参数、优化工作流程和妥善处理边缘情况,开发者可以充分发挥Apple设备的硬件潜力,构建出色的视频应用。

参考资料

  1. Apple官方文档 - VideoToolbox
  2. WWDC2021 - 探索低延迟视频编码
  3. H.264/HEVC码流格式详解
  4. VideoToolbox性能优化指南
  5. FFmpeg与VideoToolbox集成
相关推荐
用户095 天前
Flutter构建速度深度优化指南
android·flutter·ios
namehu6 天前
搞定 iOS App 测试包分发,也就这么简单!😎
前端·ios·app
用户096 天前
如何避免写垃圾代码:iOS开发篇
ios·swiftui·swift
HarderCoder6 天前
iOS 知识积累第一弹:从 struct 到 APP 生命周期的全景复盘
ios
goodSleep6 天前
更新Mac OS Tahoe26用命令恢复 Mac 启动台时不小心禁用了聚焦搜索
macos
叽哥7 天前
Flutter Riverpod上手指南
android·flutter·ios
用户098 天前
SwiftUI Charts 函数绘图完全指南
ios·swiftui·swift
YungFan8 天前
iOS26适配指南之UIColor
ios·swift
权咚8 天前
阿权的开发经验小集
git·ios·xcode
用户098 天前
TipKit与CloudKit同步完全指南
ios·swift