标签 滤镜 下的文章

前言

在做图片相关功能时,有一个需求几乎绕不开:
用户拖动参数,图片实时变化。

比如:

  • 调整模糊强度
  • 改变对比度、饱和度
  • 预览滤镜效果,再决定是否应用

在 UIKit 时代,我们可能会用 UIImageView + CoreImage + GCD 硬撸。
但到了 SwiftUI,很多人第一反应是:

SwiftUI + CoreImage + 实时预览,这事靠谱吗?

答案是:靠谱,但得用对方式。

这篇文章就从一个最小可用 Demo开始,一步一步把实时滤镜预览这件事讲清楚。

先说结论:实时预览的关键点是什么?

在 SwiftUI 里做 CoreImage 实时预览,核心其实只有三点:

  1. 图片渲染要尽量轻
  2. 滤镜计算不能阻塞主线程
  3. UI 状态变化要最小化

如果你一上来就把所有滤镜计算都丢进 body
那基本等于在和 SwiftUI 的刷新机制正面硬刚。

一个最基础的目标效果

我们先定一个目标:

  • 显示一张原图
  • 拖动 Slider
  • 实时调整高斯模糊强度
  • 图片随着 Slider 连续变化

这是绝大多数滤镜编辑页的基础形态。

Step 1:准备 CoreImage 的基础组件

先把 CoreImage 的几个核心对象准备好:

import SwiftUI
import CoreImage
import CoreImage.CIFilterBuiltins

let context = CIContext()
let filter = CIFilter.gaussianBlur()

这里有两个细节值得注意:

  • CIContext 应该尽量复用
  • 不要在 body 里反复 new CIContext

CIContext 本身是重量级对象,频繁创建会直接拖垮性能。

Step 2:一个最简单的 SwiftUI 结构

我们先搭一个最基础的页面结构:

struct ContentView: View {
    @State private var intensity: Double = 0.5
    let image = UIImage(named: "example")!

    var body: some View {
        VStack {
            Image(uiImage: image)
                .resizable()
                .scaledToFit()

            Slider(value: $intensity)
                .padding()
        }
    }
}

到这一步,UI 是没问题的,但还没有任何滤镜逻辑

Step 3:把 CoreImage 滤镜接进来

关键思路是:
不要直接操作 UIImage,而是用 CIImage 作为中间态。

我们先写一个专门负责“生成滤镜图片”的方法:

func applyProcessing() -> UIImage {
    let beginImage = CIImage(image: image)
    filter.inputImage = beginImage
    filter.radius = Float(intensity * 20)

    guard let outputImage = filter.outputImage else {
        return image
    }

    if let cgimg = context.createCGImage(outputImage, from: beginImage!.extent) {
        return UIImage(cgImage: cgimg)
    }

    return image
}

这段代码做了几件事:

  1. UIImage 转成 CIImage
  2. 设置滤镜参数
  3. 通过 CIContext 渲染成 CGImage
  4. 再转回 UIImage

Step 4:把实时预览“接”到 SwiftUI 状态上

接下来是最关键的一步:
让 SwiftUI 在 Slider 变化时刷新图片,但不炸性能。

先引入一个新的状态:

@State private var processedImage: UIImage?

然后改造 body

var body: some View {
    VStack {
        Image(uiImage: processedImage ?? image)
            .resizable()
            .scaledToFit()

        Slider(value: $intensity)
            .padding()
            .onChange(of: intensity) { _ in
                processedImage = applyProcessing()
            }
    }
}

此时你已经可以看到:

  • Slider 一动
  • 图片跟着变
  • 滤镜是实时的

但——
这还不是一个“能上线”的写法。

性能问题从哪开始暴露?

当你快速拖动 Slider 时,会发现:

  • UI 有轻微卡顿
  • 真机上比模拟器更明显
  • 图片越大,问题越严重

原因也很直接:

滤镜计算跑在主线程。

Slider 的 onChange 本身就在主线程,
CoreImage 渲染又是 CPU / GPU 混合操作,
自然会影响 UI 响应。

Step 5:把滤镜计算移出主线程

一个简单、有效的方式是:
Task + MainActor 控制线程切换。

改造 onChange

.onChange(of: intensity) { _ in
    Task.detached {
        let output = applyProcessing()
        await MainActor.run {
            processedImage = output
        }
    }
}

这样做之后:

  • 滤镜计算在后台执行
  • UI 只负责展示结果
  • 拖动 Slider 明显顺滑很多

这一步,是“能不能实时预览”的分水岭。

再往前一步:为什么 SwiftUI 特别适合做这件事?

如果你用 UIKit 做过类似功能,会发现:

  • 手动管理线程
  • 手动刷新 ImageView
  • 状态和 UI 同步很痛苦

而 SwiftUI 的优势在于:

  • 状态驱动 UI
  • 图片只是状态的一个映射
  • 滤镜逻辑和 UI 逻辑可以完全解耦

你只需要保证一件事:

状态更新是轻的,计算是异步的。

一点真实项目里的经验总结

在真实项目中,我一般会遵守这几个原则:

  1. Slider 变化频繁时,必要时做节流
  2. 滤镜链尽量复用,不要每次 new
  3. 大图先 downscale 再做预览
  4. 最终导出时再跑一次“高质量渲染”

实时预览追求的是“看起来对”
而不是“每一帧都是最终质量”

总结

SwiftUI 并不是不适合做图像处理,
而是不能用同步思维去写异步计算

一旦你把:

  • CoreImage 的计算
  • SwiftUI 的状态刷新
  • 主线程和后台线程的职责

这三件事理顺了,
实时滤镜预览这件事,其实比 UIKit 时代要轻松得多。

App 名:光子相机/Photon Camera

核心功能:

  1. 仿徕卡/富士/理光/索尼/宾得独家滤镜,还原度 90%+
  2. 多种样式的水印边框效果

滤镜来自我的一个开源项目: https://github.com/bjzhou/3dlut-creator
相比刚发布时候解决了两个重大问题:

  1. 原素材色彩差异过大导致的色彩断层问题
  2. 数据在 CPU-GPU 之间互相拷贝非常耗时,现在改为纯 GPU 实现,性能大幅提升

App 目前仅上架 Google Play ,地址: https://play.google.com/store/apps/details?id=com.hinnka.mycamera

促销代码

8CB4S8EU08D702N2R6SQ6HP
VUZBWY723NXAU8XA4RPBKLC
4Y8B8P5B49G68Q9SMKKLP00
G7D8FV16710ERCUA2MKY70V
3450LYJZ36MBENHXPV6LKZE
84WJ199WGKEDG62STQF8KRB
T5T328B0WX81J2DSLJUQE1S
0N6083L48DYWGQ7SC9VPET8
40648YVDSE9T5K0F3LTWPNJ
AHQJV5Q654E6XHBDU5UHK8Z
RKV1S4G5855KX76NRHKRAWW
DW1FKJPG0LE7BNKXWMCZPVF
13F01FWZLMLHRT76HVG8AHB
S8E7761X4E4H5XZ8FXE2Z1Z
1WK0UEWC7SL7570NMSF69CM
22UL3SLX6R1NJ9UBZLV1KMP
ERZBWZN9RZ5NZHZ6XGZTP17
FWAF5SRZB17VH7NC2VXTK90
V99FPSLQ9UGXAZYEA52UG5G
RY3JUDPW5DSD8C03E9428K8
MBDMYVSU23UPU7SQ638DK3A
VTDWMH0EM4HRX2ZSJ859RGM
BCTS70FB6GMJQL4JS4RJAX4
491DPFFNH2MVT7VHLPRMQ4U
2XAQ45Q8G6RVNMBQ3T0MPD9
5P3V75GZZEEQQKSRGSW0ZNF
ZS9KTJ0Q16ASZFKUVLJQV5C
9JR9L2AKP8NBPZD49P1S0ZC
WU0TWVQ5N32AQZY50ZHPDKJ
BT37HRBWLNEUMDB879FNWGE
1Z7Q76DG0WJ2221C4VJLV9H
1AMVGNGW51K3P7YPJB1YUUU
A3TGZDVADGPFGM3T9KS5BF5
HNPAYZL4YL94RREY8AAZ7LV
5E3FUH6S8PA3S3M30QLRF92
NBXKQGKPPDGPR00B02JL1AG
9UQ1LMEZMPT92R6C8N8F30S
TPWZKY3X11Z5ZVMJZYKJBV7
XX97Y320KX9Z3EFQDWLETT5
C7XQGJLG1SH04LWNEE0TXPZ
9H6FR4Q1DWJS6Z9934V5EHE
9XC97E19N1C2DSXYV0JMX85
PVBQGRD6CL80P41P3468FPP
R71J52WS8AH7DDMA35AYZJY
G250HE201HG048VYQXH72B6
PKWRUA9RBC1FHQG0B7YQ1CD
EFDSVGL8PN9FENTH26SA03R
1FKT2XFN8C5X67WQCVYL1JS
8RJ8MG1YZX79EWAAYJEJU36
UKG7P6PF1E3P6U1W4CTVF55