普通视图

发现新文章,点击刷新页面。
今天 — 2025年7月7日iOS

开发者必看:如何在 iOS 应用中完美实现动态自定义字体!

作者 iOS新知
2025年7月7日 11:26

这里每天分享一个 iOS 的新知识,快来关注我吧

前言

作为 App 开发来说,字体在应用中扮演着至关重要的角色。一个精心选择的字体能够让你的应用在众多竞争者中脱颖而出。

image.png

但是,无论你选择哪种字体,都必须确保它的核心功能——可读性。

在以前,只有苹果自带的系统字体支持动态调整大小,而自定义字体则不支持。但自从 iOS 11 以来,这种情况已经改变。现在,你可以轻松地在动态字体中使用你的自定义字体。

今天就来讲讲如何在动态字体中使用自定义字体。

什么是动态字体?

苹果早在 iOS 7 时就引入了动态字体,旨在让用户选择他们偏好的文本大小以满足自身需求。

在较大的文本尺寸下,各种文本样式(如 .headline, .subheadline, .body, .footnote, .caption1, .caption2, .largeTitle, .title1, .title2, .title3.callout)的权重、大小和行距值可以参考苹果的人机界面指南 - 动态字体尺寸[1]。

image.png

动态字体的实现

动态字体与文本样式一起起作用,文本样式用于为每种文本大小设定缩放因子。例如,.caption2 是最小的文本样式,不会缩小到小于 11 号的大小,因为那样会很难阅读。在最小、小、中和大文本大小下,.caption2 文本样式的大小将保持在 11pt。

要获取动态字体,我们可以使用 UIFont 类方法 preferredFont(forTextStyle:) 来初始化字体。

let label = UILabel()
label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true 

设置 adjustsFontForContentSizeCategory 为 true,可以在设备的内容大小类别更改时自动更新字体。

上面的代码将返回一个苹果 San Francisco 常规字体,大小为 17(大文本尺寸下的 body 样式),以下是大文本大小上的所有文本样式的示例。

image.png

调整字体大小

可以通过以下方式更改字体大小:

  1. 打开系统设置 - 显示与亮度 - 文字大小。

  2. 通过拖动滑块调整字体大小。

image.png

调整到更大的字体

通过上边的方法调整字体到一定大小就不能再大了,其实还有办法可以调到更大:

  1. 打开设置 - 辅助功能 - 显示与文字大小 - 更大字体。

  2. 打开更大字体开关。

  3. 通过拖动滑块调整字体大小。

image.png

调试阶段修改文本大小

在开发阶段,还可以直接从 Xcode 调整字体。

  1. 点击调试栏中的图标 Environment Overrides 按钮.

  2. 打开 Dynamic Type 开关.

  3. 通过拖动滑块调整字体大小。

image.png

使用自定义字体

在 iOS 11 中,苹果引入了 UIFontMetrics,使我们的代码更简单。通过它,我们可以创建指定文本样式的 UIFontMetrics,然后将自定义字体传递给 scaledFont(for:) 方法,以获得基于自定义字体的字体对象,具有适当的样式信息,并自动缩放以匹配当前动态字体设置。

let customFont = UIFont(name: "Merriweather-Regular", size: 17)! // <1>
let label = UILabel()
label.adjustsFontForContentSizeCategory = true
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont) // <2>

<1> 我们初始化了自定义字体。在这个例子中,我使用了 Google 字体的 Merriweather 字体。
<2> 我们定义了 UIFontMetrics 的 body 文本样式,然后用它来缩放我们的自定义字体。

支持自定义字体的动态类型

虽然 UIFontMetrics 可以减少我们在支持动态类型的自定义字体上的工作量,但它并不是万能的。有时候我们仍然需要做一些额外的工作。

scaledFont(for:) 方法会根据大文本尺寸的基础字体大小应用缩放因子。苹果在 iOS 人机界面指南中说明了系统字体的字体度量标准。你可以用它作为为每种文本样式定义自定义字体的起始。

以下是我基于苹果度量的简单实现:

let customFonts: [UIFont.TextStyle: UIFont] = [
    .largeTitle: UIFont(name"Merriweather-Regular", size34)!,
    .title1: UIFont(name"Merriweather-Regular", size28)!,
    .title2: UIFont(name"Merriweather-Regular", size22)!,
    .title3: UIFont(name"Merriweather-Regular", size20)!,
    .headline: UIFont(name"Merriweather-Bold", size17)!,
    .body: UIFont(name"Merriweather-Regular", size17)!,
    .callout: UIFont(name"Merriweather-Regular", size16)!,
    .subheadline: UIFont(name"Merriweather-Regular", size15)!,
    .footnote: UIFont(name"Merriweather-Regular", size13)!,
    .caption1: UIFont(name"Merriweather-Regular", size12)!,
    .caption2: UIFont(name"Merriweather-Regular", size11)!
]

extension UIFont {
    class func customFont(forTextStyle styleUIFont.TextStyle) -> UIFont {
        let customFont = customFonts[style]!
        let metrics = UIFontMetrics(forTextStyle: style)
        let scaledFont = metrics.scaledFont(for: customFont)
        
        return scaledFont
    }
}

UIFontMetrics(forTextStyle: style).scaledFont(for: customFont) 替换为 UIFont.customFont(forTextStyle: style) 并再次运行即可。

let styles: [UIFont.TextStyle] = [.largeTitle, .title1, .title2, .title3, .headline, .subheadline, .body, .callout, .footnote, .caption1, .caption2]
for style in styles {
    ...
    let label = UILabel()
    label.adjustsFontForContentSizeCategory = true
    label.text = String(describing: style)
    label.font = UIFont.customFont(forTextStyle: style)    
    ...
}

最后看下效果:

image.png

image.png

image.png

结论

UIFontMetrics 可以减少我们在让自定义字体支持动态类型时所需的工作量。另外我们可能需要花一些时间来微调基础字体以确保其在所有变体中都适合,在 UIFontMetrics 的帮助下,这个过程不算负责。

希望这能帮助你更好地在应用中运用自定义字体。关于自定义动态字体,你有什么看法吗?欢迎在评论区中留言交流。

参考资料

[1]

苹果的人机界面指南: developer.apple.com/design/huma…

这里每天分享一个 iOS 的新知识,快来关注我吧

本文同步自微信公众号 “iOS新知”,每天准时分享一个新知识,这里只是同步,想要及时学到就来关注我吧!

关于openGL的一些学习记录

2025年7月7日 09:51

OpenGL 学习总结

目录

  1. 基础概念

  2. 渲染管线

  3. 着色器编程

  4. 纹理与采样

  5. iOS OpenGL ES

  6. 实际应用

  7. 性能优化

  8. 常见问题


基础概念

什么是 OpenGL?

OpenGL(Open Graphics Library)是一个跨语言、跨平台的图形渲染 API,用于渲染 2D 和 3D 图形。它提供了一套硬件无关的接口,让开发者能够利用 GPU 进行高效的图形渲染。

核心特点

  • 硬件抽象层:屏蔽不同 GPU 的差异

  • 状态机:通过设置状态来控制渲染行为

  • 立即模式 vs 保留模式:现代 OpenGL 使用保留模式

  • 可编程管线:通过着色器程序控制渲染过程

坐标系系统


// OpenGL 使用右手坐标系

// X轴:向右为正

// Y轴:向上为正  

// Z轴:向外为正(屏幕外)

  


// 顶点坐标通常在 [-1, 1] 范围内

let vertices: [Float] = [

    -1.0, -1.0, 0.0// 左下

     1.0, -1.0, 0.0// 右下

     0.01.0, 0.0   // 顶部

]


渲染管线

1. 顶点着色器阶段


// 顶点着色器

attribute vec4 position;

attribute vec2 texCoord;

varying vec2 vTexCoord;

  


void main() {

    gl_Position = position;

    vTexCoord = texCoord;

}

2. 图元装配

  • 将顶点连接成图元(点、线、三角形)

  • 进行视锥体裁剪

  • 背面剔除

3. 光栅化

  • 将图元转换为像素

  • 插值计算片段的属性

4. 片段着色器阶段


// 片段着色器

precision mediump float;

varying vec2 vTexCoord;

uniform sampler2D uTexture;

  


void main() {

    vec4 color = texture2D(uTexture, vTexCoord);

    gl_FragColor = color;

}

5. 逐片段操作

  • 深度测试

  • 模板测试

  • 混合


着色器编程

顶点着色器


// 基础顶点着色器

attribute vec4 aPosition;

attribute vec2 aTexCoord;

uniform mat4 uModelViewProjectionMatrix;

  


varying vec2 vTexCoord;

  


void main() {

    gl_Position = uModelViewProjectionMatrix * aPosition;

    vTexCoord = aTexCoord;

}

片段着色器


// 基础片段着色器

precision mediump float;

  


varying vec2 vTexCoord;

uniform sampler2D uTexture;

uniform float uAlpha;

  


void main() {

    vec4 color = texture2D(uTexture, vTexCoord);

    gl_FragColor = vec4(color.rgb, color.a * uAlpha);

}

常用内置变量

  • gl_Position:顶点位置(顶点着色器输出)

  • gl_FragColor:片段颜色(片段着色器输出)

  • gl_PointSize:点大小

  • gl_FragCoord:片段坐标

数据类型


// 标量类型

float, int, bool

  


// 向量类型

vec2, vec3, vec4

ivec2, ivec3, ivec4

bvec2, bvec3, bvec4

  


// 矩阵类型

mat2, mat3, mat4

  


// 采样器类型

sampler2D, samplerCube


纹理与采样

纹理创建


func createTexture(from image: UIImage) -> GLuint {

    guard let cgImage = image.cgImage else { return 0 }

    

    var textureName: GLuint = 0

    glGenTextures(1, &textureName)

    glBindTexture(GLenum(GL_TEXTURE_2D), textureName)

    

    // 设置纹理参数

    glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR)

    glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_LINEAR)

    glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_S), GL_CLAMP_TO_EDGE)

    glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_T), GL_CLAMP_TO_EDGE)

    

    // 上传纹理数据

    let width = cgImage.width

    let height = cgImage.height

    let colorSpace = CGColorSpaceCreateDeviceRGB()

    let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)

    

    let context = CGContext(data: nil,

                           width: width,

                           height: height,

                           bitsPerComponent: 8,

                           bytesPerRow: width * 4,

                           space: colorSpace,

                           bitmapInfo: bitmapInfo.rawValue)!

    

    context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))

    

    let data = context.data!

    glTexImage2D(GLenum(GL_TEXTURE_2D), 0, GL_RGBA, GLsizei(width), GLsizei(height), 0, GLenum(GL_RGBA), GLenum(GL_UNSIGNED_BYTE), data)

    

    return textureName

}

纹理坐标


// 纹理坐标 (0,0) 在左下角,(1,1) 在右上角

let texCoords: [Float] = [

    0.0, 0.0// 左下

    1.0, 0.0// 右下

    0.0, 1.0// 左上

    1.0, 1.0   // 右上

]

纹理过滤


// 最近邻过滤

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_NEAREST)

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_NEAREST)

  


// 线性过滤

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR)

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_LINEAR)

  


// 多级纹理过滤

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR_MIPMAP_LINEAR)

glGenerateMipmap(GLenum(GL_TEXTURE_2D))


iOS OpenGL ES

初始化


import GLKit

  


class OpenGLView: GLKView {

    private var context: EAGLContext!

    private var program: GLuint = 0

    

    override init(frame: CGRect) {

        // 创建 OpenGL ES 2.0 上下文

        context = EAGLContext(api: .openGLES2)!

        super.init(frame: frame, context: context)

        

        // 设置代理

        self.delegate = self

        

        // 设置像素格式

        self.drawableColorFormat = .RGBA8888

        self.drawableDepthFormat = .format24

        

        // 设置内容缩放因子

        self.contentScaleFactor = UIScreen.main.scale

        

        // 设置当前上下文

        EAGLContext.setCurrent(context)

        

        // 初始化 OpenGL 状态

        setupOpenGL()

    }

    

    private func setupOpenGL() {

        // 启用深度测试

        glEnable(GLenum(GL_DEPTH_TEST))

        

        // 设置清除颜色

        glClearColor(0.0, 0.0, 0.0, 1.0)

        

        // 创建着色器程序

        program = createShaderProgram()

    }

}

渲染循环


extension OpenGLView: GLKViewDelegate {

    func glkView(_ view: GLKView, drawIn rect: CGRect) {

        // 清除缓冲区

        glClear(GLbitfield(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT))

        

        // 使用着色器程序

        glUseProgram(program)

        

        // 设置顶点数据

        setupVertexData()

        

        // 设置纹理

        setupTexture()

        

        // 绘制

        glDrawArrays(GLenum(GL_TRIANGLES), 0, 6)

    }

}

着色器编译


func createShaderProgram() -> GLuint {

    let vertexShaderSource = """

    attribute vec4 position;

    attribute vec2 texCoord;

    varying vec2 vTexCoord;

    

    void main() {

        gl_Position = position;

        vTexCoord = texCoord;

    }

    """

    

    let fragmentShaderSource = """

    precision mediump float;

    varying vec2 vTexCoord;

    uniform sampler2D uTexture;

    

    void main() {

        gl_FragColor = texture2D(uTexture, vTexCoord);

    }

    """

    

    // 编译顶点着色器

    let vertexShader = compileShader(type: GLenum(GL_VERTEX_SHADER), source: vertexShaderSource)

    

    // 编译片段着色器

    let fragmentShader = compileShader(type: GLenum(GL_FRAGMENT_SHADER), source: fragmentShaderSource)

    

    // 创建程序

    let program = glCreateProgram()

    glAttachShader(program, vertexShader)

    glAttachShader(program, fragmentShader)

    glLinkProgram(program)

    

    // 检查链接状态

    var linkStatus: GLint = 0

    glGetProgramiv(program, GLenum(GL_LINK_STATUS), &linkStatus)

    if linkStatus == GL_FALSE {

        print("Program link failed")

        glDeleteProgram(program)

        return 0

    }

    

    // 清理着色器

    glDeleteShader(vertexShader)

    glDeleteShader(fragmentShader)

    

    return program

}

  


func compileShader(type: GLenum, source: String) -> GLuint {

    let shader = glCreateShader(type)

    var cSource = (source as NSString).utf8String

    var length = GLint(source.utf8.count)

    glShaderSource(shader, 1, &cSource, &length)

    glCompileShader(shader)

    

    // 检查编译状态

    var compileStatus: GLint = 0

    glGetShaderiv(shader, GLenum(GL_COMPILE_STATUS), &compileStatus)

    if compileStatus == GL_FALSE {

        var infoLength: GLint = 0

        glGetShaderiv(shader, GLenum(GL_INFO_LOG_LENGTH), &infoLength)

        var infoLog = [GLchar](repeating: 0, count: Int(infoLength))

        glGetShaderInfoLog(shader, infoLength, nil, &infoLog)

        print("Shader compilation failed: \(String(cString: infoLog))")

        glDeleteShader(shader)

        return 0

    }

    

    return shader

}


实际应用

图片渲染


class ImageRenderer {

    private var program: GLuint = 0

    private var vertexBuffer: GLuint = 0

    private var texture: GLuint = 0

    

    func setup() {

        // 创建顶点缓冲区

        let vertices: [Float] = [

            // 位置        // 纹理坐标

            -1.0, -1.0, 0.00.0, 0.0,

             1.0, -1.0, 0.01.0, 0.0,

            -1.01.0, 0.00.0, 1.0,

             1.0, -1.0, 0.01.0, 0.0,

             1.01.0, 0.01.0, 1.0,

            -1.01.0, 0.00.0, 1.0

        ]

        

        glGenBuffers(1, &vertexBuffer)

        glBindBuffer(GLenum(GL_ARRAY_BUFFER), vertexBuffer)

        glBufferData(GLenum(GL_ARRAY_BUFFER), MemoryLayout<Float>.size * vertices.count, vertices, GLenum(GL_STATIC_DRAW))

    }

    

    func render() {

        glUseProgram(program)

        

        // 设置顶点属性

        glBindBuffer(GLenum(GL_ARRAY_BUFFER), vertexBuffer)

        

        let positionLocation = glGetAttribLocation(program, "position")

        let texCoordLocation = glGetAttribLocation(program, "texCoord")

        

        glEnableVertexAttribArray(GLuint(positionLocation))

        glEnableVertexAttribArray(GLuint(texCoordLocation))

        

        let stride = GLsizei(MemoryLayout<Float>.size * 5)

        glVertexAttribPointer(GLuint(positionLocation), 3, GLenum(GL_FLOAT), GLboolean(GL_FALSE), stride, nil)

        glVertexAttribPointer(GLuint(texCoordLocation), 2, GLenum(GL_FLOAT), GLboolean(GL_FALSE), stride, UnsafeRawPointer(bitPattern: 3 * MemoryLayout<Float>.size))

        

        // 设置纹理

        glActiveTexture(GLenum(GL_TEXTURE0))

        glBindTexture(GLenum(GL_TEXTURE_2D), texture)

        glUniform1i(glGetUniformLocation(program, "uTexture"), 0)

        

        // 绘制

        glDrawArrays(GLenum(GL_TRIANGLES), 0, 6)

        

        glDisableVertexAttribArray(GLuint(positionLocation))

        glDisableVertexAttribArray(GLuint(texCoordLocation))

    }

}

滤镜效果


// 灰度滤镜

void main() {

    vec4 color = texture2D(uTexture, vTexCoord);

    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));

    gl_FragColor = vec4(vec3(gray), color.a);

}

  


// 反色滤镜

void main() {

    vec4 color = texture2D(uTexture, vTexCoord);

    gl_FragColor = vec4(1.0 - color.rgb, color.a);

}

  


// 模糊滤镜

void main() {

    vec2 texelSize = 1.0 / textureSize(uTexture, 0);

    vec4 sum = vec4(0.0);

    

    for(int i = -2; i <= 2; i++) {

        for(int j = -2; j <= 2; j++) {

            vec2 offset = vec2(float(i), float(j)) * texelSize;

            sum += texture2D(uTexture, vTexCoord + offset);

        }

    }

    

    gl_FragColor = sum / 25.0;

}


性能优化

1. 批处理


// 合并多个绘制调用

func batchRender(objects: [GameObject]) {

    // 按材质分组

    let groupedObjects = Dictionary(grouping: objects) { $0.material }

    

    for (material, objects) in groupedObjects {

        // 绑定材质

        bindMaterial(material)

        

        // 批量绘制

        for object in objects {

            updateTransform(object.transform)

            drawObject(object)

        }

    }

}

2. 顶点缓冲区优化


// 使用 VBO 存储顶点数据

func createVertexBuffer() {

    let vertices: [Float] = [/* 顶点数据 */]

    

    glGenBuffers(1, &vertexBuffer)

    glBindBuffer(GLenum(GL_ARRAY_BUFFER), vertexBuffer)

    glBufferData(GLenum(GL_ARRAY_BUFFER), MemoryLayout<Float>.size * vertices.count, vertices, GLenum(GL_STATIC_DRAW))

}

3. 纹理优化


// 使用纹理图集

func createTextureAtlas() {

    // 将多个小纹理合并到一个大纹理中

    // 减少纹理切换次数

}

  


// 使用压缩纹理

func loadCompressedTexture() {

    // 使用 PVRTC 或 ASTC 格式

    // 减少内存占用和带宽

}

4. 着色器优化


// 避免分支语句

// 不好的做法

if (condition) {

    color = texture2D(tex1, coord);

} else {

    color = texture2D(tex2, coord);

}

  


// 好的做法

color = mix(texture2D(tex1, coord), texture2D(tex2, coord), condition ? 1.0 : 0.0);

  


// 使用内置函数

// 不好的做法

float length = sqrt(x * x + y * y);

  


// 好的做法

float length = length(vec2(x, y));


常见问题

1. 纹理显示问题

问题:纹理显示为黑色或白色

原因

  • 纹理数据格式不匹配

  • 纹理坐标错误

  • 采样器设置问题

解决


// 检查纹理格式

glTexImage2D(GLenum(GL_TEXTURE_2D), 0, GL_RGBA, width, height, 0, GLenum(GL_RGBA), GLenum(GL_UNSIGNED_BYTE), data)

  


// 检查纹理坐标

let texCoords: [Float] = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0]

  


// 设置正确的采样器

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR)

glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_LINEAR)

2. 深度测试问题

问题:物体渲染顺序错误

解决


// 启用深度测试

glEnable(GLenum(GL_DEPTH_TEST))

glDepthFunc(GLenum(GL_LESS))

  


// 清除深度缓冲区

glClear(GLbitfield(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT))

3. 内存泄漏

问题:OpenGL 资源未正确释放

解决


deinit {

    // 释放纹理

    if texture != 0 {

        glDeleteTextures(1, &texture)

    }

    

    // 释放缓冲区

    if vertexBuffer != 0 {

        glDeleteBuffers(1, &vertexBuffer)

    }

    

    // 释放着色器程序

    if program != 0 {

        glDeleteProgram(program)

    }

}

4. 性能问题

问题:渲染性能低下

解决

  • 减少绘制调用次数

  • 使用批处理

  • 优化着色器

  • 使用 LOD(细节层次)

  • 启用背面剔除


调试技巧

1. 着色器调试


// 在片段着色器中输出调试信息

void main() {

    vec4 color = texture2D(uTexture, vTexCoord);

    

    // 输出红色通道作为调试

    gl_FragColor = vec4(color.r, 0.0, 0.0, 1.0);

}

2. 状态检查


func checkOpenGLState() {

    // 检查帧缓冲区状态

    let status = glCheckFramebufferStatus(GLenum(GL_FRAMEBUFFER))

    if status != GLenum(GL_FRAMEBUFFER_COMPLETE) {

        print("Framebuffer not complete: \(status)")

    }

    

    // 检查着色器编译状态

    var compileStatus: GLint = 0

    glGetShaderiv(shader, GLenum(GL_COMPILE_STATUS), &compileStatus)

    if compileStatus == GL_FALSE {

        // 获取错误信息

        var infoLength: GLint = 0

        glGetShaderiv(shader, GLenum(GL_INFO_LOG_LENGTH), &infoLength)

        var infoLog = [GLchar](repeating: 0, count: Int(infoLength))

        glGetShaderInfoLog(shader, infoLength, nil, &infoLog)

        print("Shader compilation failed: \(String(cString: infoLog))")

    }

}

3. 性能分析


// 使用 Instruments 进行性能分析

// 关注以下指标:

// - GPU 使用率

// - 绘制调用次数

// - 纹理内存使用

// - 顶点处理数量


总结

OpenGL 是一个强大的图形渲染 API,掌握它需要:

  1. 理解渲染管线:从顶点到像素的完整流程

  2. 掌握着色器编程:GLSL 语言和 GPU 编程

  3. 熟悉纹理系统:纹理创建、采样和过滤

  4. 学会性能优化:批处理、内存管理、算法优化

  5. 掌握调试技巧:状态检查、错误处理、性能分析

通过持续学习和实践,OpenGL 将成为你图形编程的强大工具。记住:

  • 从简单开始,逐步增加复杂度

  • 重视性能优化

  • 养成良好的调试习惯

  • 关注最新的 OpenGL 特性和最佳实践

AppStore教你一招免备案的骚操作!

作者 iOS研究院
2025年7月7日 09:15

前言

书接上文AppStore的肃清了没有备案的产品,后台很多留言询问怎么样可以不备案?

好好好,想卡Bug,又不想花钱,还不想有风险

行吧,参考了很多资料和证明咨询了AppStore,终于找出来一条免备案的骚操作

如何操作?

No1. 需要从AppStore开发者后台,向苹果审核团队发起审核相关的疑问。

联系技术支持.png

No2. 耐心等待审核团队邮件,如实提供资料。

苹果回复

No3. 免责声明。

尊敬的审核团队:

   你好,非常感谢您给我提供这样一个回复的机会。
   兹证明Apple ID:xxxxxx@qq.com,持有人为xxx,我申请的免备案AppleID为:xxxxxx,
   Bundle ID:com.xxxxxx.xxxx.xxx
身份证号码为:xxxxxxxxxxx,居住地址为:xxx省xxxx市xxx区xxx路xxx号xxx小区xxx栋xxxx-xxx。
   
   具体参考资料,请查看身份证正面和背面的照片。为了证明我是账号持有人,我还将额外提供户籍信息、居住地缴费清单,来确保我的身份真实有效。
   正面:【附图】
   背面:【附图】
   户口本:【附图】
   水费、电费:【附图】
   
   本人郑重承诺,以上所有信息真实有效,如有任何欺瞒审核团队或虚假资料本人愿意承担任何法律责任,承担一切法律后果。
   
   持有签字:xxxx 【手印】
   签字日期:xxxx年xx月xx日

因为对于国内开发者信上海是苹果的话事人,所以不需要担心语言沟通问题。

⚠️关于免责声明的内容,仅供参考。总之,要尽可能多得向苹果提供有效资料。提交之后,就是耐心等待结果。

最终效果

BYPASS

如果开发者邮箱收到苹果新消息,那么恭喜你已经成功跳过来备案要求。

特别说明

因为本文示例产品为单机应用,类目属于工具类。只是用了AppStore内购相关的API,其他不需要任何网络请求。所以,需要联网的产品未必适用本文内容

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# Pingpong和连连的平替,让AppStore收款无需新增持有人。

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

知识星球

更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」

一步到位:用 Very Good CLI × Bloc × go_router 打好 Flutter 工程地基

作者 Daniel02
2025年7月6日 00:11

目录

  1. 创建flutter项目
  2. 国际化:l10n 带来的不仅是翻译
  3. 路由:为什么选 go_router?
  4. 状态管理:Cubit + Bloc + Repository 分层模型
  5. 错误提示策略:SnackbarMessage
  6. 资源管理:flutter_gen 的价值
  7. 调试与可观测性

在开发一个 Flutter 应用时,通常需要考虑以下几个方面:

  • 国际化(i18n):支持多语言,提升用户体验;
  • 状态管理:管理页面之间或组件之间的数据状态;
  • 资源引入:如图片、字体等静态资源的管理与使用;
  • 路由管理:实现页面跳转和导航逻辑;
  • 用户状态切换:处理未登录到已登录状态的转变;
  • 错误提示机制:如登录失败时的错误反馈展示。

本文将以一个 Instagram 登录页为例,从 0 开始搭建一个 Flutter 项目,逐步实现上述功能,构建一个可持续开发的项目架构。本文完整代码:github.com/wutao23yzd/… 中的Demo5,效果如下:

1.创建flutter项目

创建flutter项目,采用very_good_cli,(pub.dev/packages/ve… 创建 Flutter 项目的好处在于,它提供了规范化的项目结构、严格的代码分析规则、内建测试和 CI 支持,帮助开发者快速搭建高质量、可维护的应用,特别适合团队协作和企业级项目开发。通过如下指令,可以创建一个指定组织名和包名的flutter应用

very_good create flutter_app flutter_instagram_clone_app --org "com.flutter--application-id "com.flutter.futter_instagram_clone"


2. 国际化 (l10n)

l10n 是 “localization” 的缩写(l + 10个字母 + n),即“本地化/国际化”。very_good_cli创建好项目后,会自动生成国际化相关文件,但没有中文,可以这样子添加:

  • 在 pubspec.yaml 中确保已经添加了 flutter_localizations 依赖。
  • 在 l10n.yaml 配置文件中添加中文(zh)支持。
# 添加中文支持
preferred-supported-locales:
  - en
  - zh
  • 在 arb 目录下添加中文的 ARB 文件(如 app_localizations_zh.arb),并翻译内容
  • 添加新的文案时,使用flutter gen-l10n 重新生成本地化代码。

3. 路由:为什么选 go_router

路由使用go_routterpub.dev/packages/go… 是 Flutter 官方推荐的路由管理库,,支持嵌套路由、参数传递、URL 同步、重定向和导航守卫等高级功能。相比传统的 Navigator,它结构更清晰、代码更简洁,而且支持基于用户登录状态的路由重定向逻辑,比如登录、登出,可以通过如下代码跳转:

  redirect: (context, state) {
      final authenticated = appBloc.state.status == AppStatus.authenticated;
      final authenticating = state.matchedLocation == AppRoutes.auth.route;

      if (!authenticated) return AppRoutes.auth.route;
      if (authenticating && authenticated) return AppRoutes.home.route;

      return null;
  }


4. 状态管理:Cubit + Bloc + Repository 分层模型

在 Flutter 项目中,采用 Cubit + Bloc + Repository 的分层模型 是一种清晰、可维护性强的架构设计。它将业务逻辑、状态管理和数据访问进行职责分离(pub.dev/packages/bl…

  • Repository 层负责与数据源(如 API、数据库、缓存)交互,提供统一的数据获取接口。
  • Bloc/Cubit 层负责管理状态和业务逻辑,从 Repository 获取数据并根据用户行为更新状态。
  • **UI 层(Widget)**只关心状态展示,通过监听 Bloc/Cubit 提供的状态流进行响应式更新。

在提供的Demo中,AuthRepository 统一产出 用户身份流,任何需要身份信息的层(AppBloc)都只订阅这一个来源。

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return RepositoryProvider(
      // 全局唯一的数据层
      create: (_) => AuthRepository(),
      child: BlocProvider(
        create: (context) => AppBloc(
          authRepository: context.read<AuthRepository>(),
        ),
        child: const AppView(),
      ),
    );
  }
}
  • app.dart 顶层依赖注入,暴露一个Stream表示全局身份状态。
  • LoginCubit 只负责表单状态与调用 AuthRepository.login
  • AppBloc 只订阅 AuthRepository.user,再映射成 authenticated / unauthenticated

这样 UI ↔︎ 业务 ↔︎ 数据 的依赖方向清晰且单向。


5. 错误提示策略:SnackbarMessage 队列化

使用BlocListener<LoginCubit, LoginState>监听当前状态,如果出现错误,则顶部弹出提示;使用clearIfQueue 清除旧消息 。

BlocListener<LoginCubit, LoginState>(
  listenWhen: (p, c) => p.status != c.status && c.status.isError,
  listener: (_, s) => openSnackbar(
    SnackbarMessage.error(title: '错误', description: s.message!),
    clearIfQueue: true,
  ),
  child: const LoginForm(),
);

6. 资源管理:flutter_gen 的价值

首先要要安装flutter_gen 依赖,同时在dev_dependencies中,需要安装

build_runner: ^2.5.4
flutter_gen_runner: ^5.10.0

对于svg的文件,还需要安装依赖flutter_svg;然后按照demo中所示,在pubspec.yaml中提供资源所示路径;最后,执行dart run build_runner build自动生成assets.gen.dart 文件,引用资源文件如下所示:

Image.asset(Assets.images.logoPng.path);
FontFamily(Assets.fonts.montserrat);  // 类型安全 + IDE 自动补全
  • 不再担心路径拼写错误
  • 对 Lottie / SVG 同样适用

7. 调试与可观测性

class AppBlocObserver extends BlocObserver {
  @override
  void onChange(BlocBase bloc, Change change) =>
      log('[Bloc] ${bloc.runtimeType} $change');
}
  • 实时跟踪 Bloc / Cubit 状态变迁
  • Flutter DevTools:开启 “Enhance tracing for user widgets”

写在最后
Demo大量代码使用了www.youtube.com/watch?v=xr5…

老司机 iOS 周报 #341 | 2025-07-07

作者 ChengzhiHuang
2025年7月6日 19:13

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

新手推荐

🐎 Don ‘ t Liquid Glass All the Things

@阿权:iOS 26 的液体玻璃效果让大家眼前一亮,大家可能已经在重新设计自己的 App,恨不得给所有 UI 都加上液态玻璃效果。文章提到液态玻璃效果容易滥用,导致界面的不和谐。使用液态玻璃的场景应该是用于突出按钮下方的内容,例如地图预览上方的操作按钮。换句话说,液态玻璃效果的控件会让控件自身与下方内容悬浮出来,形成两个解耦的交互层级,如果控件本身与内容是嵌合的二维依赖关系,则并不适合添加液态玻璃效果。

文章

🐢 深入解析| Cursor 编程实践经验分享

@Cooper Chen:本文深度剖析 AI 编程助手 Cursor 的进阶使用方法,为开发者提供一套可落地的效率提升方案:

1.Prompt 设计黄金法则

  • 采用"目标-背景-约束"三段式结构
  • 技术方案设计阶段明确禁止生成代码
  • 单测生成时附带示例代码确保风格统一

2.Rules 规范引擎

  • 自动生成项目专属开发规范(支持 Go/Java 等)
  • 中间件调用错误率降低至 0.3%
  • 通过"/"命令快速适配团队规范

3.工具链整合方案

  • 复杂需求使用 AutoGPT 进行任务分解
  • 技术调研调用 Claude 深度研究模式
  • 钉钉文档直接解析免去格式转换

本文提供的技术方案设计模板和开发规范 Rules 可直接复用,帮助开发者快速建立 AI 辅助编程工作流。文中揭示的"代码生成 + 架构决策"分层协作模式,为现代软件开发提供了高效的智能解决方案。

🐕 Flutter 里的像素对齐问题,深入理解为什么界面有时候会出现诡异的细线?

@david-clang:Flutter 界面中出现的诡异细线,本质原因是:

  1. 逻辑像素到物理像素转换出现浮点值(非整数 DPR、布局误差)。
  2. Skia 默认开启 AAA(Analytic Anti-Aliasing)抗锯齿处理,处理相邻同色元素时各自计算的像素覆盖率总和可能不足 100%(如 40% + 50% = 90%)。
  3. 未被覆盖的剩余部分(如上例的 10%)会显露背景色,形成半透明的细线。

解决方案是:

  1. pixel_snap:提前将逻辑像素换算物理像素,根本上避免转换后出现物理像素不对齐。
  2. Impeller: MSAA(Multisample Anti-Aliasing)抗锯齿处理,通过在每个像素内部采样多个点来获得更准确的边缘渲染效果,使那些原本因浮点误差产生的“半像素边缘”更加平滑自然,从而视觉上弱化或隐藏了细线问题。

🐕 Rewriting a 12 Year Old Objective-C iOS App with Claude Code

@Smallfly: 这是一篇非常详实的 AI 辅助开发实践分享。作者用 Claude Code 将一个 12 年前的 Objective-C 应用 Vinylogue 重写为 Swift + SwiftUI,仅用 7 天时间就完成了从架构升级到 App Store 上架的全流程。

文章的价值在于:

  1. 真实的成本分析 - 详细记录了理论花费 $353 vs 实际花费 $20 的对比,以及每日开发进度
  2. 实用的最佳实践 - 总结了大量 Claude Code 使用技巧,如使用 --quiet 标志、合理管理上下文窗口、创建反馈循环等
  3. 架构升级经验 - 展示了如何利用 AI 工具进行大规模重构,从传统架构升级到现代的 swift-dependencies + swift-sharing 架构
  4. 完整的开发流程 - 涵盖了从数据迁移、UI 适配到自动化截图生成的全过程

对于想要尝试 AI 辅助开发的 iOS 开发者来说,这篇文章提供了一个很好的参考框架。特别是文章中提到的"保持在宏观层面评估代码库,让 AI 处理微观层面的工作"这一理念,对提高开发效率很有启发意义。

🐕 Understanding and Improving SwiftUI Performance

@AidenRao:Airbnb 的 SwiftUI 性能优化分享:通过为视图自定义 Equatable 协议实现,仅在实际数据变化时触发重绘,避免不必要的视图更新。将大型视图分解为小型可差异化组件,配合复杂度检测工具(如 SwiftLint 规则)预警重构时机,减少单次渲染计算量。

代码

container

@老驴:Apple 最近发布了一个新的开源项目叫 Container,本质上是一个运行在 Linux 上,基于 Swift 和 Virtualization framework 的容器库。它的重点是更好地支持 Apple Silicon 芯片跑容器。 个人猜测,这可能是 Apple 为将来在自家服务器上使用 Apple Silicon 做准备的一步。毕竟一直有传言说 Apple 想让自家数据中心的服务器用上自研芯片,而要做到这一点,一个完善的容器方案是少不了的。

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)

autorelease pool

作者 无数山
2025年7月6日 17:40
  1. 有两个observer会监听runloop两个事件,一个observer监听runloop要进入的是时候entry,会调用pool push方法创建一个autorelease pool

  2. 另一个observer监听runloop的状态,当runloop要进入休眠状态时beforewaiting,会pop一个自动释放池,同时push创建一个新的自动释放池。

  3. AutoreleasePoolPage 结构

    class AutoreleasePoolPage
    {
    const magic
    id *next 指向下一个可以存放被释放对象的地址
    pthread_t const thread 当前所在的线程
    AutoreleasePoolPage *const parent 当前page的父节点
    AutoreleasePoolPage *child
    
    
    }
    
  4. 每个page占4096个字节也就是4kb,自身成员变量只占56个字节,也就是7个成员变量,每个成员变量占8个字节。其他四千多个字节都是用来存放被autorelease修饰的对象内存地址。

  5. pool_boundary的作用是区分不同自动释放,调用push时,会传入一个pool_boundary并返回一个地址,这个地址不存储@autorelease对象的地址,起到一个标识作用,用来分隔不同的autoreleasepool

  6. 调用pop的时候,会传入end地址,从后到前调用对象的release方法,直到pool_boundary为止。

  7. 如果存在多个page,会从child的page最末尾开始调用,直到pool_boundary

  8. page是一个栈结构,释放是从栈顶开始

  9. 多层嵌套会共用一个page,通过pool_boundary来分隔,优先释放在里层的pool,因为最里层的pool中的对象被放倒了栈顶,优先释放栈顶对象。

    @autoreleasepool {
         NSObject *p1 = [[NSObject alloc] init]
         NSObject *p2 = [[NSObject alloc] init]
              @autoreleasepool {
                     NSObject *p3 = [[NSObject alloc] init]
                            @autoreleasepool{
                                   NSObject *p4 = [[NSObject alloc] init]
    }
    }
    }
    

16476988032851.jpg

  1. 释放时机:如果通过代码添加一个autoreleasepool,在作用域结束时,随着pool的释放,就会释放pool中的对象,这种情况是几十释放的,并不依赖于runloop。另一个就是系统自动释放的,系统会在runloop开始的时候创建一个pool,结束的时候会对pool中对象执行release操作。
  2. autoreleasepool 和 runloop的关系

16509481525421.jpg

昨天 — 2025年7月6日iOS

Xcode16报错: SDK does not contain 'libarclite' at the path '/Applicati

作者 90后晨仔
2025年7月5日 11:28

xcode 16运行项目报如下错误:

SDK does not contain 'libarclite' at the path '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/arc/libarclite_iphonesimulator.a'; try increasing the minimum deployment target

解决方案:

  • 一、错误原因是在这个路径下边缺少一个libarclite_iphonesimulator.a文件,那就command + G打开这个路径看一下,结果发现这个目录下边没有arc这个文件夹。如下图所示:
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/

Snip20250705_1.png

  • 二、点击这里下载arc文件下载下来放到这个路径下边再次运行就不报错了。这里需要注意下,就是必须是command + C + command + V复制粘贴arc这个目录下,不能拖拽,拖拽的是快捷方式不是真实的文件。

Snip20250705_2.png

下载地址我已经放到github了,需要的可以自行下载。

谈一谈iOS线程管理

作者 finger24480
2025年7月4日 21:51

前言

iOS 线程管理是一个老生常谈的话题,也是新人绕不过去的一道难关。用好多线程不容易,下面我简单谈一谈我的体会,希望对屏幕前的你有所帮助。

一、什么时候需要多线程

首先,要知道线程不是越多越好,创建线程和切换线程都有一定的开销,线程使用的不当也容易造成崩溃。那么什么时候需要使用多线程呢?一个主要的衡量标准是这个操作是否耗时,比如读写文件、网络请求、加解密等。特别是IO密集的操作,一定是要多线程的,否则会阻塞当前线程。

其次,线程和队列有着紧密的联系(ios里面特指GCD队列),如果某些操作需要按照一定的时序来执行并且对执行的时间不是那么敏感的话,那么最好就是放在一个串行队列里,比如写缓存。如果这些操作对执行时间敏感,且不是很讲究顺序的话,那么放在并行队列里比较合适,比如从分批下载视频片段(例如dash和hls)。如果是对执行时间敏感,并且又有一定的执行顺序,那么可以考虑NSOperationQueue,或者用dispatch_group、dispatch_semaphore来管理多个线程及其依赖关系。如果对这些都不讲究,那就用不着多线程了。

二、同步还是异步

一般情况下,能用异步还是用异步,除非是需要等待结果返回的才用同步。这主要是因为同步操作会阻塞线程,弄的不好还会导致死锁。编写同步代码的话,主要是用在同步读取某些属性这种场景,比如以下这个方法

- (BOOL)hasSubscribeTopic:(NSString*)topic {

        __block BOOL subscribed = NO;

        dispatch_sync(self.syncQueue, ^{

            subscribed = [self.subscribedTopics containsObject:topic];

        });

        return subscribed;


}

但是这样写有一个问题,就是如果别的方法在syncQueue对应的线程上调用了hasSubscribeTopic这个方法,就会导致死锁,所以正确的方式应该是这样

static const void * kDispatchQueueSpecificKey = &kDispatchQueueSpecificKey;
//init方法中调用
dispatch_queue_set_specific(_syncQueue, kDispatchQueueSpecificKey, (__bridge void *)self, NULL);

- (BOOL)hasSubscribeTopic:(NSString*)topic {

    void* value = dispatch_get_specific(kDispatchQueueSpecificKey);

    if (value == (__bridge void *)(self)) {

        return [self.subscribedTopics containsObject:topic];

    }else{

        __block BOOL subscribed = NO;

        dispatch_sync(self.syncQueue, ^{

            subscribed = [self.subscribedTopics containsObject:topic];

        });

        return subscribed;

    }

}

有些第三方库没有注意这方面,比如SDWebImage的SDImageCache,使用的时候就需要尤其注意

- (void)storeImageDataToDisk:(nullable NSData *)imageData

                      forKey:(nullable NSString *)key {

    if (!imageData || !key) {

        return;

    }

    

    dispatch_sync(self.ioQueue, ^{

        [self _storeImageDataToDisk:imageData forKey:key];

    });

}

三、串行还是并行

这个如前所述,主要看对执行时间的敏感程度和有无顺序要求。一般使用dispatch_create创建的队列以串行为主(swift的dispatchQueue默认就是串行的)。并行队列使用global_queue就可以了,但是有一个需要特别注意的是,不管是dispatch_get_global_queue还是dispatch_create分配的线程都是有上限的,如果超出上限,系统要么就是等待前面的线程执行完成(iOS模拟器),要么就会因为分配资源过多而导致崩溃(iOS真机)。通过下面这段代码,可以测试出系统最多能分配多少个线程,在iphone 15的模拟器上我测试得到的是global_queue能分配64个左右线程,而dispatch_create相对多一点,100多不到200个。

dispatch_queue_t syncQueue = dispatch_queue_create("test", DISPATCH_QUEUE_SERIAL);

    for (int i=0;i<1000;i++) {

        dispatch_async(dispatch_get_global_queue(0, 0), ^{

            pthread_t tid = pthread_self();

            printf("1 tid=%lu\n",(unsigned long)tid);

            dispatch_sync(syncQueue, ^{

                NSLog(@"2");

            });

            NSLog(@"3");

        });

    }

还有一个问题就是,使用dispatch_get_global_queue创建的线程看似64个也够用了,但如果在这些线程里面使用了同步操作等待串行队列执行完成的话,就会造成阻塞,最终超出线程数量上限而崩溃。比如将上面代码中的NSLog(@"2") 改为一个写缓存之类的耗时操作。

四、线程池

由于线程数量是有上限的,并且线程切换比较耗时,所以对于性能要求较高的程序需要有线程池来管理多线程。iOS是没有系统自带的线程池的,一般都是自己实现(推荐使用dispatch_semaphore或NSOperationQueue,具体实现可以参考java的executor相关代码。需要注意的是,什么时候切换到线程池是有讲究的,一般规则是逻辑层的代码尽早切换到线程池,特别是有些逻辑可能会创建多个线程的时候,比如多个图片的下载和缓存。

五、线程的同步

线程的同步也是一个比较经典的话题了,我在这里就不想赘述了,大家可以在网上随便搜一搜,我只提一下,一般线程间同步就几种方式:

  1. 加锁
  2. 条件变量 3.信号量
  3. 串行队列+同步读异步写
  4. 内存屏障
  5. CAS原子操作

个人比较推荐的是加锁(性能要求没那么高)和条件变量(性能要求较高,逻辑相对简单的场景)。串行队列如果管理不当可能会创建多个线程,因此不做推荐。内存屏障和CAS原子操作比较底层,使用起来也没那么方便,除非是对时序和性能要求极高。

六、线程间通信

除了使用C语言的pthrea_create和pthread_join来进行线程创建和销毁时的通信外,iOS还可以使用NSMachPort和NSThread的performSelectorOnThread来做线程间通信。前者跟runloop结合,在runloop的生命周期内注册一个特定的事件来定期检查并执行,后者类似于pthread_create,在创建线程时传递一个参数。 除了这种系统提供的方法外,还有一种通用的方式,就是在线程内维护一个事件队列,外部需要给这个线程发消息时,就往队列插入一个事件,然后该线程在一个循环内定时去取事件执行。有点类似runloop的感觉,如果要跨平台的话可以考虑使用libevent(一般用来做网络通信)来实现。

结语

不管是在iOS还是其他的平台上,多线程管理都是一个复杂的话题。要用好多线程,除了要掌握一些常见的方法外,最主要还是平时编程的时候多思考,什么时候应该用多线程,以及怎么样做好线程同步和队列的选择,在追求高性能的同时保证安全性。

昨天以前iOS

iOS断点下载

作者 RyanGo
2025年7月4日 17:25

断点下载:是指在下载一个较大文件过程中因网络中断等主动暂停下载,当重启任下载任务时,能够从上次停止的位置继续下载,而不用重新下载。

知识点:

1.URLSession及其任务管理

URLSessionDownloadTask:是实现断点下载的核心类,专门用于下载文件到临时位置,并原生支持断点续传:

相关代码:

let configuration = URLSessionConfiguration.default

var downloadTask : URLSessionDownloadTask?

let fileURL = URL(string: "http://vjs.zencdn.net/v/oceans.mp4")

任务下载

let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

downloadTask = session.downloadTask(with: URLRequest(url: fileURL!))
downloadTask?.resume()

继续下载

let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

downloadTask = session.downloadTask(withResumeData: data)
downloadTask?.resume()

取消下载

downloadTask?.cancel(byProducingResumeData: { [weak self] resumeData in
   self?.downloadTask = nil
   // 其他操作
}

2.数据持久化

下载的过程本身是不处理相关数据的存储的,需要我们自己来实现。数据持久化的方式很多但支持断点下载功能的多半都是比较大型的文件。因此选择沙盒(SandBox)来存储下载的文件是十分合适的。

获取文件目录:一般都是把文件存储到documentDirectoryuserDomainMask目录

let fileManager = FileManager.default

let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!

创建写入文件路径:这里表示把文件写入MyVideos文件,文件名为:oceans.mp4

let folderName = documentDaiectory.appendingPathComponent("MyVideos")

let videoURL = folderName.appendingPathComponent("oceans.mp4")

在上一步获取文件目录已经指定了一个根目录这个会沙盒系统的根目录下再创建一个MyVideos文件

// 创建需要的文件目录
do {
   try fileManager.createDirectory(at: folderName, withIntermediateDirectories: true, attributes: nil)
   // 写入文件
} catch {
   print("创建目录失败:\(error)")
}

写入文件

do {
    try data.write(to: videoURL)
    print("写入成功")
} catch {
    print("写入失败:\(error)")
}

下次继续下载时要去做一个判断,查看是否已经存储之前下载的内容,返回TRUE则是进行继续下载,返回FALSE则是重新开始下载

let fileManager = FileManager.default

guard let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
   return false
}

let documentsFileURL = documentDirectory.appendingPathComponent("MyVideos/oceans.mp4")

if fileManager.fileExists(atPath: documentsFileURL.path) {
  do {
      // 存在 
      // 同时获取当一已下载文件的Data
      self.currentDownloadData = try Data(contentsOf: documentsFileURL)
      return true
  } catch {
      return false
  }
} else {
  return true
}

在对返回的状态做相应的处理

if isFileExist() == true {
   // 继续下载
} else {
  // 重新下载
}

3.URLSessionDownloadDelegate

除了相关下载存储操作外还要实现 URLSessionDownloadDelegate 相关代理方法

下载完成:通过URLSessionDownloadTask下载完成的文件并不会存储到指定的文件夹,而是存储在sandbox的tmp目录下的临时文件夹内。该文件夹内的数据随时都会被系统清理,因此要在适当的时候把文件转移到我们需要的文件下。

这里我们把文件存储到""MyVideos"文件下并使用"oceans.mp4"为文件名

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
   let fileManager = FileManager.default
   let documentDirectory = FileManager.default.urls(for:.documentDirectory, in: .userDomainMask).first!
   let fileURL = documentDirectory.appendingPathComponent("MyVideos/oceans.mp4")

   do {
      if isFileExist() == true {
         // 还是对文件是否存在做一个判断并做一个删除处理,因为沙盒系统本身不会自动覆盖同名文件的处理
         try fileManager.removeItem(at: fileURL)
      }
      
      // 移动到指定目录
      try fileManager.moveItem(at: location, to: fileURL)
   } catch {
      print("删除文件出错:\(error)")
   }
}

下载过程中方法:可以从该方法获取到下载的进度

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
   self.currentProgressValue = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
}

核心代码

import UIKit
import Foundation

typealias DownloadProgressBlock = (_ progreee : Float) -> ()
typealias DownloadFileBlock = (_ fileURL : URL) -> ()

class WZGVideoDownloader : NSObject {
    static var shard = WZGVideoDownloader()

    var progressBlock : DownloadProgressBlock?
  
    var fileBlock : DownloadFileBlock?

    let configuration = URLSessionConfiguration.default
    var downloadTask : URLSessionDownloadTask?
    let fileURL = URL(string: "http://vjs.zencdn.net/v/oceans.mp4")
    
    // 存储已下载data
    var currentDownloadData : Data?

    // 当前文件大小
    var currentProgressValue : Float = 0.0
    
    func startDownload(_ fileSize : Data) {
        let fileManager = FileManager.default
        let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
        let documentFileURL = documentDirectory.appendingPathComponent("MyVideos/oceans.mp4")

        let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

        // 判断是继续下载还是重新下载
        if isFileExist() == true {
            if let data = self.currentDownloadData {
                if data == fileSize {
                    self.progressBlock?(1)
                    self.fileBlock?(documentFileURL)
                    return
                }
                self.progressBlock?(self.currentProgressValue)

                // 继续下载
                print("继续下载")
                downloadTask = session.downloadTask(withResumeData: data)
                downloadTask?.resume()
            }

        } else {
            // 重新下载
            print("重新下载")
            downloadTask = session.downloadTask(with: URLRequest(url: fileURL!))
            downloadTask?.resume()
        }
    }
    
    func stopDownload() {
        downloadTask?.cancel(byProducingResumeData: { [weak self] resumeData in
            guard let resumeData = resumeData else {
                return
            }
            self?.writeSandBox(resumeData)
            self?.downloadTask = nil
        })
    }
    
    // 判断是否有下载的文件

    func isFileExist() -> Bool {
        let fileManager = FileManager.default
        guard let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
            return false
        }

        let documentsFileURL = documentDirectory.appendingPathComponent("MyVideos/oceans.mp4")

        if fileManager.fileExists(atPath: documentsFileURL.path) {
            do {
                self.currentDownloadData = try Data(contentsOf: documentsFileURL)
                print("currentDownloadData:\(currentDownloadData)")
                return true
            } catch {
                return false
            }
        } else {
            return false
        }
    }
    
    // 写入sandbox

    func writeSandBox(_ data : Data) {
        let fileManager = FileManager.default
        let documentDaiectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!

        //创建目录几写入文件名
        let folderName = documentDaiectory.appendingPathComponent("MyVideos")

        //设置写入文件名称
        let videoURL = folderName.appendingPathComponent("oceans.mp4")

        // 创建目录
        do {
            try fileManager.createDirectory(at: folderName, withIntermediateDirectories: true, attributes: nil)
            // 写入文件
            do {
                try data.write(to: videoURL)
                print("写入成功")
            } catch {
                print("写入失败:\(error)")
            }
        } catch {
            print("创建目录失败:\(error)")
        }
    }
}

extension WZGVideoDownloader : URLSessionDownloadDelegate {
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        let fileManager = FileManager.default
        let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
        let fileURL = documentDirectory.appendingPathComponent("MyVideos/oceans.mp4")
        do {
            if isFileExist() == true {
                // 文件存在则删除
                try fileManager.removeItem(at: fileURL)
            }
            // 下载完会保存在temp零食文具目录 转移至需要的目录
            try fileManager.moveItem(at: location, to: fileURL)
            self.fileBlock?(fileURL)
        } catch {
            print("删除文件出错:\(error)")
        }
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        self.currentProgressValue = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
        DispatchQueue.main.async {
            self.progressBlock?(self.currentProgressValue)
        }
    }
}

关于OC与Swift内存管理的解惑

作者 丶皮蛋菌
2025年7月4日 14:01
  • 在Swift中,如何解决闭包的循环引用?
myClosure = { [weak self] in
    guard let self = self else { return }
}
  • 那么如果是多个闭包嵌套呢?
// ✅ 只需要在最外层写一次 
myClosure1 = { [weak self] in [weak self]
    // 在顶部进行一次安全解包
    guard let self = self else { return }
    // 在这个作用域里,`self` 是一个临时的强引用,可以安全使用
    // 不用担心它被提前释放
    myClosure2 = {
        // 它会捕获上面 guard let 创建好的、非可选的强引用 self
        myClosure3 = {
            // 你可以直接、安全地调用 self 的方法或属性
        }
    }
}
  • 那么又有一个问题:在OC中的block,就需要每一层都要弱引用,第二层要先强引用再弱引用,第三层再先强引用再弱引用吧?为什么Swift不是这个逻辑? 这是因为Swift 的逻辑确实和 OC 不一样,它在这方面做了极大的简化和安全性提升。

OC 的核心问题在于:   @strongify(self) 创建的那个临时强引用 strongSelf 的生命周期会持续到包含它的那个 block 执行完毕。如果这个 block 内部又启动了一个长时间的异步任务(第二个 block),那么 strongSelf 会被第二个 block 捕获,导致 self 实例的生命周期被不必要地延长。因此,为了追求最精细的内存管理,开发者才会在每一层异步调用前都重复进行“弱化 -> 强化”的操作。

Swift 的优势在于:  guard let self = self 创建的强引用 self 是一个全新的、仅存在于外层闭包作用域内的局部变量。当 anotherAsyncTask 的闭包(内层闭包)创建时,它捕获的是这个局部的、新的 self。一旦外层闭包执行完毕(someAsyncTask 的回调结束),这个局部的 self 变量就会被销毁。anotherAsyncTask 的闭包对它的持有也就自然解除了,完全不会影响到原始 self 实例的生命周期。

特性 / 行为 Objective-C (block) Swift (closure)
弱引用声明 @weakify(self) 或 __weak typeof(self) weakSelf = self; 在捕获列表 [weak self]
临时强引用 @strongify(self) 或 __strong typeof(weakSelf) strongSelf = weakSelf; guard let self = self else { return }
嵌套捕获 内层 block 捕获由 @strongify 创建的 strongSelf,其生命周期可能过长,导致需要“再次弱化”。 内层闭包捕获由 guard let 创建的局部强引用 self。该局部变量生命周期很短,因此无需再次弱化
开发者操作 需要警惕并可能在每一层异步调用前都重复“弱化-强化”的模式。 只需要在最外层做一次“弱化-强化” ,内部可以完全放心使用。
  • 但是在实际开发中,swift代码提示引用报错,往往xcode是这样解决的,为什么?
someClosure = { [self] in }

那是因为Xcode 的首要任务是解决编译错误,而不是帮你分析内存管理。  而 [self] in 正是解决这个特定编译错误的“最直接”的语法。它解决了语法问题,但它没有解决循环引用的问题。它只是把一个隐式的强引用,变成了一个显式的强引用。所以解决循环引用问题还是需要[weak self]

  • 那么在实际开发中[self]对于内存泄露来说是错误的呗?

这个说法不完全准确,但您的警惕性非常对!更精确的说法是: 在【会】产生循环引用的场景下使用 [self],是绝对错误的,它会直接导致内存泄漏。 但是,在【不会】产生循环引用的场景下,[self] 则是安全、甚至是被推荐的写法。 所以,[self] 本身不是“错误”,它只是一个工具。错误的是在不合适的场景下使用了这个工具。

比如下列两种情况

// 这个闭包被传递给 UIView.animate,执行完动画后就会被销毁。 
// self 并没有一个属性来持有这个闭包。 
// 所以 self -> 闭包 这条强引用链不存在。 
UIView.animate(withDuration: 0.5) { [self] in 
    // 在这里使用 [self] in 是【完全正确】的。 
    // 它明确地告诉编译器:“我知道我在强引用 self,且我确定这是安全的。” 
}

// DispatchQueue.main.asyncAfter 的闭包同样是执行完就销毁。 
DispatchQueue.main.asyncAfter(deadline: .now() +1.0) { [self] in 
    // 这里使用 [self] in 也是【完全正确】的。 
}

简单的可以理解为“一次性”的工作 (用 [self] 是安全的),“长期”的规则 (必须用 [weak self])。

  • 那么又有一个场景,在对网络请求进行二次封装的情况下,在调用网络请求时,是否需要弱引用?

答案是不需要的。为什么呢?因为Alamofire临时持有了您的闭包,由于 ViewController 没有持有任何东西,所以闭包无论如何强引用 ViewController,都构不成一个闭环。因此,这里使用 [self] 来显式强引用是完全安全的。

  • 为什么感觉 OC 的 AFNetworking 封装调用时不需要弱引用?

无论是在 OC 的 AFNetworking 还是 Swift 的 Alamofire,它们【内部都没有,也不可能】自动处理您在闭包中捕获 self 导致的循环引用。防止循环引用的责任始终在调用者(也就是您)身上。

  • 那为什么您会有“AFN不需要弱引用”的印象呢?

原因和我们上面分析的完全一样:因为您在 OC 中调用 AFN 封装的场景,很可能也属于“一次性”的调用,本身就不会产生循环引用。由于AFHTTPSessionManager的实例 manager 是一个局部变量,方法执行完就释放了,success block 被 manager 临时持有,执行完也就释放了。所以,当时您在 OC 里不写弱引用是正确的,不是因为 AFNetworking 内部处理了,而是因为您的【用法】决定了它根本没有循环引用!

[MyOCNetworkManager requestWithURL:@"" parameters:nil success:^(id responseObject) { 
    // 您在这里直接使用 self,比如 [self.tableView reloadData]; 
    // 并没有写 __weak typeof(self) weakSelf = self; 
} failure:^(NSError *error) { 
    // ... 
}];
  • 那么在OC+AFNetworking的二次封装回调时,如果我依然写弱引用的话,会有问题吗?

完全没有问题,这样做在内存上是绝对安全的,但同样,它也是不必要的,并且有轻微的副作用。 在那个场景下,编写弱引用代码(即 weak/strong dance)是“安全但多余的”。

我们来对比一下两种写法:

  1. 在您调用一个“一次性”的网络请求封装时,闭包不会被您的 ViewController 持有,因此不存在循环引用。
[MyOCNetworkManager requestWithURL:@"" 
                        parameters:nil 
                           success:^(id responseObject) {
    // 直接使用 self,闭包会强引用 self。
    // 因为没有循环引用,self 会在闭包执行完后被正常释放,这是安全的。
    [self.tableView reloadData];
} 
                           failure:nil];

这种写法简洁、清晰,并且正确地表达了意图:“这是一个一次性的任务,我需要 self 在任务执行时是存在的。”

  1. 如果您坚持使用弱引用,代码会是这样:
__weak typeof(self) weakSelf = self;
[MyOCNetworkManager requestWithURL:@"/some/path" 
                        parameters:nil 
                           success:^(id responseObject) {
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) {
        return;
    }
    // 使用 strongSelf 来确保在闭包执行期间 self 不会被释放
    [strongSelf.tableView reloadData];
} 
                           failure:nil];
  1. 内存安全吗?

是的,100%安全。弱引用永远不会增加引用计数,所以它绝不会“创造”出一个循环引用。从这个角度看,它没有任何“问题”。

  1. 有什么副作用或缺点吗?  

有,和 Swift 的情况完全相同:

代码变得冗余:为了一个没有必要的安全措施,您多写了三行样板代码 (__weak__strongif)。这降低了代码的简洁性。

意图变得模糊:当其他开发者读到这段代码时,他们会看到 weak/strong dance,这通常是一个强烈的信号,表示“这里有循环引用的风险”。他们可能会因此花时间去寻找一个实际并不存在的风险点,增加了维护成本。

极端情况下的功能差异:如果 self (比如一个 UIViewController) 在网络请求发出后、但在回调执行前被释放了(例如用户快速返回上一个页面),那么:

写法一(强引用) :self 会被闭包“续命”,直到闭包执行完毕。[self.tableView reloadData] 会被执行。

写法二(弱引用) :self 会被立即释放。当回调执行时,strongSelf 会是 nil,代码会直接 return[strongSelf.tableView reloadData] 不会被执行。

[MyOCNetworkManager requestWithURL:@"" 
                        parameters:nil 
                           success:^(id responseObject) {
    // 直接使用 self,闭包会强引用 self。
    // 因为没有循环引用,self 会在闭包执行完后被正常释放,这是安全的。
    [self.tableView reloadData];
} 
                           failure:nil];
 __weak typeof(self) weakSelf = self;
[MyOCNetworkManager requestWithURL:@"" 
                        parameters:nil 
                           success:^(id responseObject) {
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) {
        return;
    }
    // 使用 strongSelf 来确保在闭包执行期间 self 不会被释放
    [strongSelf.tableView reloadData];
} 
                           failure:nil];
  • 极端情况下的功能差异:如果 self (比如一个 UIViewController) 在网络请求发出后、但在回调执行前被释放了(例如用户快速返回上一个页面),写法二(弱引用):self 会被立即释放。当回调执行时,strongSelf 会是 nil,代码会直接 return,[strongSelf.tableView reloadData] 不会被执行。 但是实际中的这种写法很常见,我以前就是这么写,但是没有发现什么问题啊?返回页面还是会执行dealloc‌,为什么?
  1. 大多数情况下,行为差异“无关紧要”

我们回到最常见的场景:网络请求回来后,更新界面。

Objective-C

// 弱引用写法
__weak typeof(self) weakSelf = self;
[MyNetworkManager request:^{
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) {
        return; // 如果 self 销毁了,就直接返回
    }
    [strongSelf.tableView reloadData]; // 更新界面
}];

设想一下用户快速返回,self (也就是 ViewController) 被销毁了。这时网络回调回来了,因为 strongSelf 是 nil,所以 [strongSelf.tableView reloadData] 这行代码没有被执行**。

这对用户来说是问题吗?完全不是。  因为界面都已经消失了,tableView 也不存在了,去刷新一个不存在的界面本来就是一件没有意义的事情。代码不执行,反而更干净利落。

所以,在 99% 的 UI 更新场景中,弱引用导致的“代码不执行”这个行为差异,不仅不是问题,反而是我们期望的、最合理的结果。

  1. “快速返回”的极端情况发生概率低

要触发这个“功能差异”,需要满足一个条件:self 的销毁发生在“网络请求发出后”和“回调执行前”这个短暂的时间窗口内。

对于大多数响应速度很快的 API 来说,这个窗口可能只有几百毫秒。用户需要操作得非常快才能正好卡在这个时间点上。因此,在日常测试和使用中,这个情况本身就不容易遇到。

  1. 即使代码不执行,也无可见负面影响

假设回调里做的事情是 [self hideLoadingIndicator]。如果用户已经返回了上一个页面,那个加载指示器 loadingIndicator 本来就已经随着页面消失了,所以 hideLoadingIndicator 这行代码执不执行,用户根本感知不到任何区别。

iOS图片编辑项目推荐

作者 wyanassert
2025年7月4日 19:18

在 GitHub 上确实有不少优秀且实用的 iOS 图片编辑相关的开源项目和 Demo。这些项目覆盖了基础编辑(裁剪、旋转、调整)、滤镜应用、涂鸦、贴纸添加、高级特效等功能。以下是一些值得关注的项目,适合学习和集成:


🛠 一、功能较全的图片编辑框架

  1. TOCropViewController

  2. YPImagePicker


🎨 二、滤镜 & 特效处理

  1. MetalPetal
    • 简介:基于 Metal 的高性能图像处理框架,支持滤镜链、实时渲染。
    • 链接https://github.com/MetalPetal/MetalPetal
    • 特点:替代 GPUImage,性能优异,适合复杂滤镜开发。

✏️ 三、涂鸦 & 标注工具

  1. SignatureView

  2. PencilKitExample


🧩 四、完整图片编辑 App Demo

  1. PhotoEditDebug


✅ 选择建议:

  • 需要快速集成裁剪功能TOCropViewController
  • 开发完整图片编辑 App → 参考 PhotoEditDebug
  • 实现高性能滤镜MetalPetal
  • 添加手绘涂鸦PencilKitSignatureView

💡 学习资源:

这些项目大多持续维护,代码质量较高。建议根据需求先尝试 Demo,再选择性集成模块到项目中!如果遇到具体实现问题,可以深入查看其 Issues 或源码实现逻辑。

❌
❌