iOS GPUImage源码解读(一)

导语:最近在不断学习、使用的过程中,有了更深刻的理解,特来写一篇源码解读的文章详细介绍下核心代码的具体实现。至于括号里的“一”,主要是觉得GPUImage还有很多值得深入学习和分享的内容,后续的学习和使用过程中有新的心得体会还会继续给大家分享。

前言

GPUImage是iOS上一个基于OpenGL进行图像处理的开源框架,内置大量滤镜,架构灵活,可以在其基础上很轻松地实现各种图像处理功能。本文主要向大家分享一下项目的核心架构、源码解读及使用心得。

GPUImage有哪些特性

  1. 丰富的输入组件
    摄像头、图片、视频、OpenGL纹理、二进制数据、UIElement(UIView, CALayer)
  2. 大量现成的内置滤镜(4大类)
    1). 颜色类(亮度、色度、饱和度、对比度、曲线、白平衡...)
    2). 图像类(仿射变换、裁剪、高斯模糊、毛玻璃效果...)
    3). 颜色混合类(差异混合、alpha混合、遮罩混合...)
    4). 效果类(像素化、素描效果、压花效果、球形玻璃效果...)
  3. 丰富的输出组件
    UIView、视频文件、GPU纹理、二进制数据
  4. 灵活的滤镜链
    滤镜效果之间可以相互串联、并联,调用管理相当灵活。
  5. 接口易用
    滤镜和OpenGL资源的创建及使用都做了统一的封装,简单易用,并且内置了一个cache模块实现了framebuffer的复用。
  6. 线程管理
    OpenGLContext不是多线程安全的,GPUImage创建了专门的contextQueue,所有的滤镜都会扔到统一的线程中处理。
  7. 轻松实现自定义滤镜效果
    继承GPUImageFilter自动获得上面全部特性,无需关注上下文的环境搭建,专注于效果的核心算法实现即可。

基本用法

// 获取一张图片
UIImage *inputImage = [UIImage imageNamed:@"sample.jpg"];
// 创建图片输入组件GPUImagePicture *sourcePicture = [[GPUImagePicture alloc] initWithImage:inputImage smoothlyScaleOutput:YES]; 
// 创建素描滤镜
GPUImageSketchFilter *customFilter = [[GPUImageSketchFilter alloc] init]; 
// 把素描滤镜串联在图片输入组件之后
[sourcePicture addTarget:customFilter];
// 创建ImageView输出组件GPUImageView *imageView = [[GPUImageView alloc] initWithFrame:mainScreenFrame];
[self.view addSubView:imageView];
// 把ImageView输出组件串在滤镜链末尾[customFilter addTarget:imageView];
// 调用图片输入组件的process方法,渲染结果就会绘制到imageView上[sourcePicture processImage];

效果如图:

整个框架的目录结构

核心架构

基本上每个滤镜都继承自GPUImageFilter;
而GPUImageFilter作为整套框架的核心;
接收一个GPUImageFrameBuffer输入;
调用GLProgram渲染处理;
输出一个GPUImageFrameBuffer;
把输出的GPUImageFrameBuffer传给通过targets属性关联的下级滤镜;
直到传递至最终的输出组件;

核心架构可以整体划分为三块:输入、滤镜处理、输出
接下来我们就深入源码,看看GPUImage是如何获取数据、传递数据、处理数据和输出数据的

获取数据

GPUImage提供了多种不同的输入组件,但是无论是哪种输入源,获取数据的本质都是把图像数据转换成OpenGL纹理。这里就以视频拍摄组件(GPUImageVideoCamera)为例,来讲讲GPUImage是如何把每帧采样数据传入到GPU的。

GPUImageVideoCamera里大部分代码都是对摄像头的调用管理,不了解的同学可以去学习一下AVFoundation(传送门)。摄像头拍摄过程中每一帧都会有一个数据回调,在GPUImageVideoCamera中对应的处理回调的方法为:

- (void)processVideoSampleBuffer:(CMSampleBufferRef)sampleBuffer;

iOS的每一帧摄像头采样数据都会封装成CMSampleBufferRef;
CMSampleBufferRef除了包含图像数据、还包含一些格式信息、图像宽高、时间戳等额外属性;
摄像头默认的采样格式为YUV420,关于YUV格式大家可以自行搜索学习一下(传送门):

YUV420按照数据的存储方式又可以细分成若干种格式,这里主要是kCVPixelFormatType_420YpCbCr8BiPlanarFullRange和kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange两种;

两种格式都是planar类型的存储方式,y数据和uv数据分开放在两个plane中;
这样的数据没法直接传给GPU去用,GPUImageVideoCamera把两个plane的数据分别取出:

- (void)processVideoSampleBuffer:(CMSampleBufferRef)sampleBuffer {    // 一大坨的代码用于获取采样数据的基本属性(宽、高、格式等等)
    ......    if ([GPUImageContext supportsFastTextureUpload] && captureAsYUV) {
        CVOpenGLESTextureRef luminanceTextureRef = NULL;
        CVOpenGLESTextureRef chrominanceTextureRef = NULL;        if (CVPixelBufferGetPlaneCount(cameraFrame) > 0) // Check for YUV planar inputs to do RGB conversion
        {
            ......
// 从cameraFrame的plane-0提取y通道的数据,填充到luminanceTextureRef
            glActiveTexture(GL_TEXTURE4);
            err = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, [[GPUImageContext sharedImageProcessingContext] coreVideoTextureCache], cameraFrame, NULL, GL_TEXTURE_2D, GL_LUMINANCE, bufferWidth, bufferHeight, GL_LUMINANCE, GL_UNSIGNED_BYTE, 0, &luminanceTextureRef);
            ......            
            // 从cameraFrame的plane-1提取uv通道的数据,填充到chrominanceTextureRef
            glActiveTexture(GL_TEXTURE5);
            err = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, [[GPUImageContext sharedImageProcessingContext] coreVideoTextureCache], cameraFrame, NULL, GL_TEXTURE_2D, GL_LUMINANCE_ALPHA, bufferWidth/2, bufferHeight/2, GL_LUMINANCE_ALPHA, GL_UNSIGNED_BYTE, 1, &chrominanceTextureRef);
            ......            
            // 把luminance和chrominance作为2个独立的纹理传入GPU
            [self convertYUVToRGBOutput];

            ......
        }
    } else {
        ......
    }  
}

注意CVOpenGLESTextureCacheCreateTextureFromImage中对于internalFormat的设置;
通常我们创建一般纹理的时候都会设成GL_RGBA,传入的图像数据也会是rgba格式的;
而这里y数据因为只包含一个通道,所以设成了GL_LUMINANCE(灰度图);
uv数据则包含2个通道,所以设成了GL_LUMINANCE_ALPHA(带alpha的灰度图);
另外uv纹理的宽高只设成了图像宽高的一半,这是因为yuv420中,每个相邻的2x2格子共用一份uv数据;
数据传到GPU纹理后,再通过一个颜色转换(yuv->rgb)的shader(shader是OpenGL可编程着色器,可以理解为GPU侧的代码,关于shader需要一些OpenGL编程基础(传送门)),绘制到目标纹理:

 // fullrange
 varying highp vec2 textureCoordinate;
 uniform sampler2D luminanceTexture;
 uniform sampler2D chrominanceTexture;
 uniform mediump mat3 colorConversionMatrix; 
 void main() {
     mediump vec3 yuv;
     lowp vec3 rgb;
     yuv.x = texture2D(luminanceTexture, textureCoordinate).r;
     yuv.yz = texture2D(chrominanceTexture, textureCoordinate).ra - vec2(0.5, 0.5);
     rgb = colorConversionMatrix * yuv;
     gl_FragColor = vec4(rgb, 1);
 }
 // videorange
 varying highp vec2 textureCoordinate;
 uniform sampler2D luminanceTexture;
 uniform sampler2D chrominanceTexture;
 uniform mediump mat3 colorConversionMatrix; void main() {
     mediump vec3 yuv;
     lowp vec3 rgb;
     yuv.x = texture2D(luminanceTexture, textureCoordinate).r - (16.0/255.0);
     yuv.yz = texture2D(chrominanceTexture, textureCoordinate).ra - vec2(0.5, 0.5);
     rgb = colorConversionMatrix * yuv;
     gl_FragColor = vec4(rgb, 1);
 }

注意yuv420fullrange和yuv420videorange的数值范围是不同的,因此转换公式也不同,这里会有2个颜色转换shader,根据实际的采样格式选择正确的shader;
渲染输出到目标纹理后就得到一个转换成rgb格式的GPU纹理,完成了获取输入数据的工作;

传递数据

GPUImage的图像处理过程,被设计成了滤镜链的形式;输入组件、效果滤镜、输出组件串联在一起,每次推动渲染的时候,输入数据就会按顺序传递,经过处理,最终输出。

GPUImage设计了一个GPUImageInput协议,定义了GPUImageFilter之间传入数据的方法:

- (void)setInputFramebuffer:(GPUImageFramebuffer *)newInputFramebuffer atIndex:(NSInteger)textureIndex {
    firstInputFramebuffer = newInputFramebuffer;
    [firstInputFramebuffer lock];
}

firstInputFramebuffer属性用来保存输入纹理;
GPUImageFilter作为单输入滤镜基类遵守了GPUImageInput协议,GPUImage还提供了GPUImageTwoInputFilter, GPUImageThreeInputFilter等多输入filter的基类。

这里还有一个很重要的入口方法用于推动数据流转:

- (void)newFrameReadyAtTime:(CMTime)frameTime atIndex:(NSInteger)textureIndex {
    ......

    [self renderToTextureWithVertices:imageVertices textureCoordinates:[[self class] textureCoordinatesForRotation:inputRotation]];

    [self informTargetsAboutNewFrameAtTime:frameTime];
}

每个滤镜都是由这个入口方法开始启动,这个方法包含2个调用
1). 首先调用render方法进行效果渲染
2). 调用informTargets方法将渲染结果推到下级滤镜

GPUImageFilter继承自GPUImageOutput,定义了输出数据,向后传递的方法:

- (void)notifyTargetsAboutNewOutputTexture;

但是这里比较奇怪的是滤镜链的传递实际并没有用notifyTargets方法,而是用了前面提到的informTargets方法:

- (void)informTargetsAboutNewFrameAtTime:(CMTime)frameTime {
    ......    
    // Get all targets the framebuffer so they can grab a lock on it
    for (id<GPUImageInput> currentTarget in targets) {        if (currentTarget != self.targetToIgnoreForUpdates) {            NSInteger indexOfObject = [targets indexOfObject:currentTarget];            NSInteger textureIndex = [[targetTextureIndices objectAtIndex:indexOfObject] integerValue];
            [self setInputFramebufferForTarget:currentTarget atIndex:textureIndex];
            [currentTarget setInputSize:[self outputFrameSize] atIndex:textureIndex];
        }
    }

    ......    
    // Trigger processing last, so that our unlock comes first in serial execution, avoiding the need for a callback
    for (id<GPUImageInput> currentTarget in targets) {        if (currentTarget != self.targetToIgnoreForUpdates) {            NSInteger indexOfObject = [targets indexOfObject:currentTarget];            NSInteger textureIndex = [[targetTextureIndices objectAtIndex:indexOfObject] integerValue];
            [currentTarget newFrameReadyAtTime:frameTime atIndex:textureIndex];
        }
    }
}

GPUImageOutput定义了一个targets属性来保存下一级滤镜,这里可以注意到targets是个数组,因此滤镜链也支持并联结构。可以看到这个方法主要做了2件事情:
1). 对每个target调用setInputFramebuffer方法把自己的渲染结果传给下级滤镜作为输入
2). 对每个target调用newFrameReadyAtTime方法推动下级滤镜启动渲染
滤镜之间通过targets属性相互衔接串在一起,完成了数据传递工作。

处理数据

前面提到的renderToTextureWithVertices:方法便是每个滤镜必经的渲染入口。
每个滤镜都可以设置自己的shader,重写该渲染方法,实现自己的效果:

- (void)renderToTextureWithVertices:(const GLfloat *)vertices textureCoordinates:(const GLfloat *)textureCoordinates {
    ......

    [GPUImageContext setActiveShaderProgram:filterProgram];

    outputFramebuffer = [[GPUImageContext sharedFramebufferCache] fetchFramebufferForSize:[self sizeOfFBO] textureOptions:self.outputTextureOptions onlyTexture:NO];
    [outputFramebuffer activateFramebuffer];
    ......

    [self setUniformsForProgramAtIndex:0];

    glClearColor(backgroundColorRed, backgroundColorGreen, backgroundColorBlue, backgroundColorAlpha);
    glClear(GL_COLOR_BUFFER_BIT);

    glActiveTexture(GL_TEXTURE2);
    glBindTexture(GL_TEXTURE_2D, [firstInputFramebuffer texture]);
    glUniform1i(filterInputTextureUniform, 2);  

    glVertexAttribPointer(filterPositionAttribute, 2, GL_FLOAT, 0, 0, vertices);
    glVertexAttribPointer(filterTextureCoordinateAttribute, 2, GL_FLOAT, 0, 0, textureCoordinates);

    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);

    ......
}

上面这个是GPUImageFilter的默认方法,大致做了这么几件事情:
1). 向frameBufferCache申请一个outputFrameBuffer
2). 将申请得到的outputFrameBuffer激活并设为渲染对象
3). glClear清除画布
4). 设置输入纹理
5). 传入顶点
6). 传入纹理坐标
7). 调用绘制方法

再来看看GPUImageFilter使用的默认shader:

 // vertex shader
 attribute vec4 position;
 attribute vec4 inputTextureCoordinate;
 varying vec2 textureCoordinate; void main() {
     gl_Position = position;
     textureCoordinate = inputTextureCoordinate.xy;
 }
 // fragment shader
 varying highp vec2 textureCoordinate;
 uniform sampler2D inputImageTexture; void main() {
     gl_FragColor = texture2D(inputImageTexture, textureCoordinate);
 }

这个shader实际上啥也没做,VertexShader(顶点着色器)就是把传入的顶点坐标和纹理坐标原样传给FragmentShader,FragmentShader(片段着色器)就是从纹理取出原始色值直接输出,最终效果就是把图片原样渲染到画面。

输出数据

比较常用的主要是GPUImageView和GPUImageMovieWriter。

GPUImageView继承自UIView,用于实时预览,用法非常简单
1). 创建GPUImageView
2). 串入滤镜链
3). 插到视图里去
UIView的contentMode、hidden、backgroundColor等属性都可以正常使用
里面比较关键的方法主要有这么2个:

// 申明自己的CALayer为CAEAGLLayer+ (Class)layerClass  {    return [CAEAGLLayer class];
}
- (void)createDisplayFramebuffer {
    [GPUImageContext useImageProcessingContext];

    glGenFramebuffers(1, &displayFramebuffer);
    glBindFramebuffer(GL_FRAMEBUFFER, displayFramebuffer);

    glGenRenderbuffers(1, &displayRenderbuffer);
    glBindRenderbuffer(GL_RENDERBUFFER, displayRenderbuffer);

    [[[GPUImageContext sharedImageProcessingContext] context] renderbufferStorage:GL_RENDERBUFFER fromDrawable:(CAEAGLLayer*)self.layer];

    GLint backingWidth, backingHeight;

    glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &backingWidth);
    glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &backingHeight);

    ......

    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, displayRenderbuffer);

    ......
}

创建frameBuffer和renderBuffer时把renderBuffer和CALayer关联在一起;
这是iOS内建的一种GPU渲染输出的联动方法;
这样newFrameReadyAtTime渲染过后画面就会输出到CALayer。

GPUImageMovieWriter主要用于将视频输出到磁盘;
里面大量的代码都是在设置和使用AVAssetWriter,不了解的同学还是得去看AVFoundation;
这里主要是重写了newFrameReadyAtTime:方法:

- (void)newFrameReadyAtTime:(CMTime)frameTime atIndex:(NSInteger)textureIndex {
    ......

    GPUImageFramebuffer *inputFramebufferForBlock = firstInputFramebuffer;
    glFinish();

    runAsynchronouslyOnContextQueue(_movieWriterContext, ^{
        ......        
        // Render the frame with swizzled colors, so that they can be uploaded quickly as BGRA frames
        [_movieWriterContext useAsCurrentContext];
        [self renderAtInternalSizeUsingFramebuffer:inputFramebufferForBlock];

        CVPixelBufferRef pixel_buffer = NULL;        
        if ([GPUImageContext supportsFastTextureUpload]) {
            pixel_buffer = renderTarget;
            CVPixelBufferLockBaseAddress(pixel_buffer, 0);
        } else {
            CVReturn status = CVPixelBufferPoolCreatePixelBuffer (NULL, [assetWriterPixelBufferInput pixelBufferPool], &pixel_buffer);            if ((pixel_buffer == NULL) || (status != kCVReturnSuccess)) {
                CVPixelBufferRelease(pixel_buffer);                return;
            } else {
                CVPixelBufferLockBaseAddress(pixel_buffer, 0);

                GLubyte *pixelBufferData = (GLubyte *)CVPixelBufferGetBaseAddress(pixel_buffer);
                glReadPixels(0, 0, videoSize.width, videoSize.height, GL_RGBA, GL_UNSIGNED_BYTE, pixelBufferData);
            }
        }

        ......
        [assetWriterPixelBufferInput appendPixelBuffer:pixel_buffer];
        ......
    });
}

这里有几个地方值得注意:
1). 在取数据之前先调了一下glFinish,CPU和GPU之间是类似于client-server的关系,CPU侧调用OpenGL命令后并不是同步等待OpenGL完成渲染再继续执行的,而glFinish命令可以确保OpenGL把队列中的命令都渲染完再继续执行,这样可以保证后面取到的数据是正确的当次渲染结果。
2). 取数据时用了supportsFastTextureUpload判断,这是个从iOS5开始支持的一种CVOpenGLESTextureCacheRef和CVImageBufferRef的映射(映射的创建可以参看获取数据中的CVOpenGLESTextureCacheCreateTextureFromImage),通过这个映射可以直接拿到CVPixelBufferRef而不需要再用glReadPixel来读取数据,这样性能更好。

最后归纳一下本文涉及到的知识点

1. AVFoundation
摄像头调用、输出视频都会用到AVFoundation
2. YUV420
视频采集的数据格式
3. OpenGL shader
GPU的可编程着色器
4. CAEAGLLayer
iOS内建的GPU到屏幕的联动方法
5. fastTextureUpload
iOS5开始支持的一种CVOpenGLESTextureCacheRef和CVImageBufferRef的映射


作者简介:billzbwang(王志斌),天天P图iOS工程师

Vue中Axios的封装和API接口的管理

在vue项目中,和后台交互获取数据这块,我们通常使用的是axios库,它是基于promise的http库,可运行在浏览器端和node.js中。他有很多优秀的特性,例如拦截请求和响应、取消请求、转换json、客户端防御XSRF等。所以我们的尤大大也是果断放弃了对其官方库vue-resource的维护,直接推荐我们使用axios库。如果还对axios不了解的,可以移步axios文档。

发布于:4天以前  |  35次阅读  |  详细内容 »

iOS 持续集成:更完备的 App Store Connect API

时隔两年 App Store Connect API 有了更新,WWDC 2018 推出了 App Store Connect API ,用于自动化一些 App Store Connect 后台操作。这次更新包含了 app 元数据相关的API,补上了原来缺失的重要一环, 使得几乎可以通过 App Store Connect API 完成 App Store Connect 上的所有操作。今后开发、证书配置、用户管理、测试、发布全流程都可以通过 API 完成。

发布于:7天以前  |  43次阅读  |  详细内容 »

iOS 性能优化:优化 App 启动速度

苹果是一家特别注重用户体验的公司,过去几年一直在优化 App 的启动时间,特别是去年的 WWDC 2019 keynote[1] 上提到,在过去一年苹果开发团队对启动时间提升了 200%

发布于:7天以前  |  50次阅读  |  详细内容 »

让你的应用远离越狱:iOS 14 App Attest 防护功能

当越狱在 iOS 设备第一次流行起来时,iOS 开发人员会尝试各种方法来保护自己的应用程序,以让应用免受盗版等不确定因素的困扰。有许多方法可以做到这一点,包括检查 Cydia 是否存在、检测应用程序是否可读取自身沙箱之外的文件、在检测到调试器时让应用程序崩溃等等。

发布于:15天以前  |  81次阅读  |  详细内容 »

探秘 iOS 14 的 WidgetKit

Widget Extension 提供了 small, medium, large 三个尺寸,不同尺寸可以展示不同的数据、不同的界面,开发者也可以锁定自己APP的 Widget 只有某类尺寸,相同的widget也能重复添加。作为添加在主屏幕上的控件,苹果用了 “At a glance” 来形容 widget ,所以 widget extension 是无法交互的,它能做的只有展示一些信息与点击两个作用,点击后就会引导至app,同时为了性能与耗电量的考虑,Widget extension 也不能展示视频和动态图像。

发布于:16天以前  |  95次阅读  |  详细内容 »

iOS14 Widget 万字指北,先人一步获得顶级流量

2020 年 6 月 22 日,苹果召开了第一次线上的开发者大会 - WWDC20。这次发布会上宣布了ARM架构Mac芯片(拳打Intel)、iOS 14 ATT(脚踢Facebook),可谓是一次载入史册(我是爸爸)的发布会了,当然还发布了被称为下一个顶级流量入口的Widget。踩着八月的尾巴,本次我们就来探究一下Widget。本文会从Widget初窥和Widget开发两个维度和章节来探究一下Widget, 其中初窥章节会带您简单的了解一下Widget,适合应用决策者阅读; 开发章节会带着您一步一步的完成设计开发Widget,适合程序员阅读。

发布于:16天以前  |  100次阅读  |  详细内容 »

OCRunner:完全体的iOS热修复方案

为了能够实现一篇文章的思路:Objective-C源码 -> 二进制补丁文件 ->热更新(具体是哪篇我忘了)。当时刚好开始了oc2mango翻译器的漫漫长路(顺带为了学习编译原理,嘻嘻),等基本完成以后,就开始肝OCRunner:完全兼容struct,enum,系统C函数调用,魔改libffi,生成补丁文件等,尽可能兼容Objective-C,为了做一个直接运行OC的快乐人。

发布于:16天以前  |  97次阅读  |  详细内容 »

iOS APP图标版本化

在我们的项目开发过程中,需要频繁打包给测试人员去测试,有时候我们都不知道测试机上安装的版本是否是最新的,这样会造成很多不必要的麻烦和成本。因此我们需要将buildNumber以水印的方式打在APPIcon上,可以很直观的知道当前是哪一个版本。

发布于:16天以前  |  85次阅读  |  详细内容 »

百度App iOS工程化实践: EasyBox破冰之旅

百度App从单一的搜索工具发展到今天以搜索和Feed流为双引擎的综合性内容消费服务平台,其复杂程度已然不可同日而语矣。 作为一个日活过亿的超级App,业务规模庞大,相关技术人员超过千人,客户端支持主流的移动技术,涉及近百业务方,技术形态复杂,各种组件近三百个,代码百万量级,由此带来的工程化问题是技术团队的一个极大挑战。

发布于:16天以前  |  104次阅读  |  详细内容 »

百度APP iOS暗黑模式适配的完美解决方案

在2019WWDC的开场演讲中,苹果公布了即将推出的iOS13 DarkMode的新特性。此新特性不仅可以在夜晚保护视力,而且对于使用OLED的最新一代设备而言,也可以帮助用户节省电量消耗。不过此特性只支持iOS13以上的系统,为了给全系统所有用户最好的体验,研发出了一套皮肤主题框架,不仅可以全系统支持DarkMode,还可以扩展多套皮肤主题;

发布于:16天以前  |  93次阅读  |  详细内容 »

iOS 14 - 使用 PHPicker 选择照片和视频

绝大多数的 App 都要和相册打交道,选择照片或者视频,要么用来发个朋友圈,要么是放到什么地方做个背景。从 AssertLibrary 到 Photos 框架,苹果已经在多年之前就给相册相关的 API 做过一次大升级了。

发布于:19天以前  |  121次阅读  |  详细内容 »

iOS开发体验优化方案

随着Flutter等跨端框架的出现,业务开发同学经常需要在Android/iOS上跨端进行业务开发,问题定位等。新的不熟悉的环境的搭建总会遇到各种各样的问题,导致搭建失败,特别是iOS开发环境,是最复杂的,不仅环境搭建繁琐,而且切分支后的打包速度很慢,所以我们设计实现了两个工具,用于优化闲鱼iOS开发体验。

发布于:1月以前  |  153次阅读  |  详细内容 »

iOS14:再见了,“流氓”APP!

最近和苹果有关的重大消息可能就是从8月1日开始,AppStore中国区火速下架未获版号的游戏APP,数量超过30000款,之前小智就和大家说过,这未必不是一件好事,众多低质和“流氓”APP将被最大限度隔绝在iOS系统之外。

发布于:1月以前  |  189次阅读  |  详细内容 »

最多阅读

快速配置 Sign In with Apple 1年以前  |  3223次阅读
给数组NSMutableArray排序 1年以前  |  2343次阅读
开篇 关于iOS越狱开发 1年以前  |  2342次阅读
APP适配iOS11 1年以前  |  2268次阅读
在越狱的iPhone设置上使用lldb调试 1年以前  |  2251次阅读
UITableViewCell高亮效果实现 1年以前  |  2173次阅读
App Store 审核指南[2017年最新版本] 1年以前  |  2095次阅读
使用 GPUImage 实现一个简单相机 1年以前  |  2062次阅读
所有iPhone设备尺寸汇总 1年以前  |  2031次阅读
使用ssh访问越狱iPhone的两种方式 1年以前  |  1947次阅读
关于Xcode不能打印崩溃日志 1年以前  |  1943次阅读
使用ssh 访问越狱iPhone的两种方式 1年以前  |  1814次阅读
UIDevice的简单使用 1年以前  |  1675次阅读
为对象添加一个释放时触发的block 1年以前  |  1633次阅读
使用最高权限操作iPhone手机 1年以前  |  1577次阅读