news 2026/5/11 11:39:07

紧抱豆包大腿,开发了一个iOS实用小工具(二维码照片清理app)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
紧抱豆包大腿,开发了一个iOS实用小工具(二维码照片清理app)

手机相册里存了很多随身码,核算码啥的图片(身在上海,经历过2020~2022年的都懂)。一直没有耐心好好去清理。但总惦记着这个事情,想写个工具app来清理相册里这种图片。

身为一名非典型性程序猿,之前只会用一些非主流的开发工具,想学iOS开发,却是举步维艰:一看object-c的代码就一个头两个大(那时还不知道swfit-UI)。曾经想过用XMarain for iOS做,也写了一个雏形,但是受限于时间和没有Mac电脑等等(都是借口而已),一拖又是几年过去了。
2025年,AI突然发力,甚至可以跟程序猿抢桃子了,我就动了用豆包帮我写这个iOS程序的念头。豆包真的很牛逼,我提了设想,它二话不说就把代码给我写完了,是个SwiftUI程序。代码很简洁,虽然也看不懂,但是好像比较偏向于高级语言。顺便多说两句灌个水,给非程序猿看官科普一下,高级语言这个“高级”不日常用语里高级感满满那个褒义词,是指编程语言更贴近自然语言,是相对于更贴近机器指令的低级语言(如C语言、汇编语言)而言。

手上没有可用的Mac设备,就折腾虚拟机吧。之前主要玩VirtualBox,因为免费。折腾了很久,装MacOS遇到各种问题。网上一看很多人推荐用VM-ware,又突然发现VM-ware workstation已经对个人免费了,于是果断弃暗投明。在VM-ware上装了一个MacOS虚拟机,再加上XCode,居然很顺利地在仿真iOS设备上把这个程序给调试通过了。步骤也很简单:在Xcode里新建一个iOS App工程,接口类型选了默认的SwiftUI,然后语言就只能选Swfit,把豆包给我写好的Swfit源码贴到主源码文件里就行了。

上真机测试的历程有点坎坷,一开始是因为我的Xcode版本太低(好像是XCode 14吧),我的手机偏偏又已经升级到了iOS 18.7,适配不了。一顿折腾,好容易升级到了MacOS 15.7.4 (Sequoia)和Xcode 16.4,终于能上真机调试了。程序跑一会儿就闪退。仿真设备相册里我只放进去40多张照片测试。真机里照片太多,估计是扫描过程中内存没及时释放,爆了,就闪退了。想调优代码,又看不太懂。反复测试,发现如果每次扫描的照片不超过70张,不会闪退。好在这个工具我主要是自己用,我自己能凑合,就改成分批次扫描,每次扫描64张。相册照片按时间从早到晚顺序排队接受检阅。每一批扫描之后,把本批次最后一张照片的日期时间记下来,下一轮扫描从这个日期时间开始的照片。扫描发现了二维码占主体的图片呢,就放到画廊里陈列出来,橱窗里显示缩略图,用户可以点开看大图,可以取消勾选。完成选择之后,工具就提示是否删除照片。说到这里,不得不表达对SwiftUI的敬佩,这些复杂的图形化UI交互,人家一个源码文件就全部搞定,代码还只有600多行。

除了这个SwfitUI的主源码文件(完全是豆包捉刀代笔),我还按照豆包的指导在XCode的工程理加了对相册权限的请求声明。(刚开始换MacOS和Xcode高版本把这一茬给忘了,程序一跑就报错,把我困惑了好几天,还以为是高版本系统不兼容代码,几乎放弃)。首先,在XCode的Project树形导航栏选择最上层的工程节点,然后在中间导航栏选择下面TARGETS区域第一个与工程同名的节点,最后在右侧的Custom iOS Target Properties列表中点+号按钮新增2项(Value对应界面提示文字,可以根据自己偏好调整):

Key

Type

Value

Privacy - Photo Library Usage Description

String

需要访问相册以扫描二维码照片

Privacy - Photo Library Additions Usage Description

String

需要访问相册以删除照片

最后,作为一个App,在桌面上得有一个Icon,随意用AI生成了一张图标,1024×1024的PNG图片。左侧工程树形导航栏里选Assets,中部导航栏选AppIcon,右侧左上角有个Any Apperance,把图片从Finder里拖进来就OK了。

以下附上主文件Swift源码,其他工程文件就不必要了。我自己没有买Apple的开发者订阅,毕竟一年要99美元,我又不是职业iOS开发程序猿,如果哪位土豪看官或者热心人想让我把这个App上架,可以赞助或者帮我众筹这笔经费。亲们如果有任何问题或者要求想跟我交流,欢迎跟我联系,eMail:zongchao@sina.com,微信号:zong_chao。

import SwiftUI import Photos import CoreImage import CoreImage.CIFilterBuiltins // MARK: - 存储断点 Key private let kLastScannedDateKey = "kLastScannedDateKey" // MARK: - 二维码检测模块 private let ciContext = CIContext(options: [.useSoftwareRenderer: false]) struct QRResult { let found: Bool let boundingBox: CGRect let imageSize: CGSize var widthRatio: Double { imageSize.width > 0 ? Double(boundingBox.width / imageSize.width) : 0.0 } var heightRatio: Double { imageSize.height > 0 ? Double(boundingBox.height / imageSize.height) : 0.0 } var isMainQRCode: Bool { widthRatio >= 0.5 || heightRatio >= 0.5 } } extension UIImage { func detectQRCode(context: CIContext) -> QRResult { guard let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: context, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) else { return QRResult(found: false, boundingBox: .zero, imageSize: size) } guard let ci = autoreleasepool(invoking: { CIImage(image: self) }), let features = detector.features(in: ci) as? [CIQRCodeFeature] else { return QRResult(found: false, boundingBox: .zero, imageSize: size) } guard let f = features.first else { return QRResult(found: false, boundingBox: .zero, imageSize: size) } let t = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -size.height) return QRResult(found: true, boundingBox: f.bounds.applying(t), imageSize: size) } } // MARK: - 相册扫描管理器 class PhotoScanner: ObservableObject { @Published var isScanning = false @Published var totalPhotos = 0 @Published var currentIndex = 0 @Published var foundCount = 0 @Published var foundAssets: [PHAsset] = [] @Published var showPermissionAlert = false @Published var lastScannedDate: Date? @Published var showNoMorePhotosAlert = false private var stopFlag = false private let ciContext = CIContext(options: [.useSoftwareRenderer: false]) private let defaults = UserDefaults.standard private let batchSize = 64 init() { lastScannedDate = defaults.object(forKey: kLastScannedDateKey) as? Date } func stopScan() { stopFlag = true } private func saveBreakpoint(date: Date) { lastScannedDate = date defaults.set(date, forKey: kLastScannedDateKey) } func clearBreakpoint() { lastScannedDate = nil defaults.removeObject(forKey: kLastScannedDateKey) foundAssets.removeAll() foundCount = 0 currentIndex = 0 } func requestPermission(completion: @escaping (Bool) -> Void) { PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in DispatchQueue.main.async { completion(status == .authorized || status == .limited) } } } func startScan() { guard !isScanning else { return } isScanning = true stopFlag = false currentIndex = 0 let opt = PHFetchOptions() opt.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] opt.fetchLimit = batchSize if let lastDate = lastScannedDate { opt.predicate = NSPredicate(format: "creationDate > %@", lastDate as CVarArg) } let assets = PHAsset.fetchAssets(with: .image, options: opt) totalPhotos = assets.count if assets.count == 0 { DispatchQueue.main.async { self.isScanning = false self.showNoMorePhotosAlert = true } return } func next(at i: Int) { if stopFlag || i >= assets.count { if i > 0, let date = assets.object(at: i-1).creationDate { saveBreakpoint(date: date) } DispatchQueue.main.async { self.isScanning = false } return } DispatchQueue.main.async { self.currentIndex = i + 1 } let asset = assets.object(at: i) loadImage(asset: asset) { img in if let img = img { let res = img.detectQRCode(context: self.ciContext) if res.found && res.isMainQRCode { DispatchQueue.main.async { self.foundCount += 1 self.foundAssets.append(asset) } } } next(at: i + 1) } } DispatchQueue.global(qos: .userInitiated).async { next(at: 0) } } private func loadImage(asset: PHAsset, completion: @escaping (UIImage?) -> Void) { let opt = PHImageRequestOptions() opt.isSynchronous = true opt.deliveryMode = .highQualityFormat opt.resizeMode = .fast let targetSize = CGSize(width: 1200, height: 1200) PHImageManager.default().requestImage( for: asset, targetSize: targetSize, contentMode: .aspectFit, options: opt ) { img, _ in autoreleasepool { completion(img) } } } } // MARK: - 详情页(点击看大图 + 缩略图240px清晰版) struct ResultDetailView: View { let assets: [PHAsset] @Binding var showDetail: Bool var onDeleteCompleted: () -> Void @State private var currentPage = 0 @State private var selectedItems: Set<Int> = [] @State private var showAlert = false @State private var alertMessage = "" @State private var isDeleteAlert = false @State private var selectedImage: UIImage? @State private var showFullScreen = false private let itemsPerPage = 9 private let columns = Array(repeating: GridItem(.flexible()), count: 3) private var totalPages: Int { max(1, Int(ceil(Double(assets.count) / Double(itemsPerPage)))) } private var pageItems: [PHAsset] { let start = currentPage * itemsPerPage let end = min(start + itemsPerPage, assets.count) return Array(assets[start..<end]) } private var pageIndices: [Int] { let start = currentPage * itemsPerPage return (0..<pageItems.count).map { start + $0 } } init(assets: [PHAsset], showDetail: Binding<Bool>, onDeleteCompleted: @escaping () -> Void) { self.assets = assets self._showDetail = showDetail self.onDeleteCompleted = onDeleteCompleted _selectedItems = State(initialValue: Set(0..<assets.count)) } var body: some View { NavigationStack { VStack(spacing: 4) { Text("请选择要删除的照片:") .font(.subheadline) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.top, 4) Text("第 \(currentPage+1)/\(totalPages) 页") .font(.caption) ScrollView(.vertical, showsIndicators: false) { LazyVGrid(columns: columns, spacing: 8) { ForEach(Array(zip(pageIndices, pageItems)), id: \.0) { idx, asset in PhotoCheckItemView( asset: asset, isChecked: selectedItems.contains(idx), onToggle: { if selectedItems.contains(idx) { selectedItems.remove(idx) } else { selectedItems.insert(idx) } }, onTapImage: { loadFullImage(asset: asset) } ) } } .padding(.horizontal, 12) } HStack(spacing: 16) { Button { if currentPage > 0 { currentPage -= 1 } } label: { Text("上一页") } .disabled(currentPage <= 0) Button { if currentPage < totalPages - 1 { currentPage += 1 } } label: { Text("下一页") } .disabled(currentPage >= totalPages - 1) Button { checkSelectionAndShowAlert() } label: { Text("完成选择") } .foregroundColor(.blue) } .padding(.vertical, 8) } .navigationTitle("二维码主体照片") .alert("提示", isPresented: $showAlert) { if isDeleteAlert { Button("是", role: .destructive) { deleteSelected() } Button("否", role: .cancel) { showDetail = false } } else { Button("确定") { showDetail = false } } } message: { Text(alertMessage) } .overlay { if showFullScreen, let selectedImage { FullScreenImageView(image: selectedImage) { self.selectedImage = nil self.showFullScreen = false } } } } } private func loadFullImage(asset: PHAsset) { let options = PHImageRequestOptions() options.deliveryMode = .highQualityFormat options.isNetworkAccessAllowed = true PHImageManager.default().requestImage( for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .aspectFit, options: options ) { image, _ in guard let image else { return } DispatchQueue.main.async { self.selectedImage = image self.showFullScreen = true } } } private func checkSelectionAndShowAlert() { if selectedItems.isEmpty { alertMessage = "共选中0张照片" isDeleteAlert = false } else { alertMessage = "共选中 \(selectedItems.count) 张照片,是否删除所选照片?" isDeleteAlert = true } showAlert = true } private func deleteSelected() { let toDelete = selectedItems.compactMap { assets.indices.contains($0) ? assets[$0] : nil } PHPhotoLibrary.shared().performChanges({ PHAssetChangeRequest.deleteAssets(toDelete as NSArray) }, completionHandler: { success, error in DispatchQueue.main.async { if success { showDetail = false onDeleteCompleted() } else { alertMessage = "删除失败:\(error?.localizedDescription ?? "未知错误")" isDeleteAlert = false showAlert = true } } }) } } // MARK: - 全屏大图查看 struct FullScreenImageView: View { let image: UIImage let onTap: () -> Void var body: some View { ZStack { Color.black.ignoresSafeArea() Image(uiImage: image) .resizable() .scaledToFit() .onTapGesture { onTap() } } } } // MARK: - 照片复选项(点击缩略图看大图) struct PhotoCheckItemView: View { let asset: PHAsset let isChecked: Bool let onToggle: () -> Void let onTapImage: () -> Void var body: some View { VStack(spacing: 2) { PhotoThumbnailView(asset: asset) .frame(height: 110) .clipped() .onTapGesture { onTapImage() } Toggle(isOn: Binding( get: { isChecked }, set: { _ in onToggle() } )) { EmptyView() } .labelsHidden() } } } // MARK: - 缩略图(80 → 240px,更清晰) struct PhotoThumbnailView: View { let asset: PHAsset @State private var image: UIImage? var body: some View { ZStack { if let image = image { Image(uiImage: image) .resizable() .scaledToFill() } else { Color.gray.opacity(0.4) } } .onAppear { let requestOptions = PHImageRequestOptions() requestOptions.isNetworkAccessAllowed = true requestOptions.deliveryMode = .highQualityFormat requestOptions.resizeMode = .fast PHImageManager.default().requestImage( for: asset, targetSize: CGSize(width: 240, height: 240), // 🔥 从80改成240,更清晰 contentMode: .aspectFill, options: requestOptions ) { image, info in DispatchQueue.main.async { self.image = image } } } } } // MARK: - 主界面 struct ContentView: View { @StateObject private var scanner = PhotoScanner() @State private var showDetail = false private var progress: Double { guard scanner.totalPhotos > 0 else { return 0 } return Double(scanner.currentIndex) / Double(scanner.totalPhotos) } private var progressText: String { "\(Int(progress * 100))%" } private var lastScanDateText: String { guard let date = scanner.lastScannedDate else { return "已扫描照片最晚日期时间:无(将从最早照片开始)" } let fmt = DateFormatter() fmt.dateFormat = "yyyy-MM-dd HH:mm:ss" return "已扫描照片最晚日期时间:\(fmt.string(from: date))" } var body: some View { ZStack { VStack(spacing: 20) { Text("本应用可以从相册中查找二维码占主体的照片") .font(.title3) .bold() .foregroundColor(.secondary) .multilineTextAlignment(.leading) .padding(.horizontal, 24) .padding(.top, 50) Button { scanner.requestPermission { granted in if granted { scanner.startScan() } else { scanner.showPermissionAlert = true } } } label: { Text("扫描二维码照片") .frame(maxWidth: .infinity) .padding() } .background(Color.blue) .foregroundColor(.white) .font(.title2.bold()) .cornerRadius(12) .padding(.horizontal, 30) Text(lastScanDateText) .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) Button { scanner.clearBreakpoint() } label: { Text("清除记忆点(从头扫描)") .frame(maxWidth: .infinity) .padding() } .background(Color.gray) .foregroundColor(.white) .font(.body.bold()) .cornerRadius(12) .padding(.horizontal, 30) Spacer() } if scanner.isScanning || scanner.currentIndex > 0 { Color.white.edgesIgnoringSafeArea(.all) VStack(spacing: 24) { if scanner.isScanning { Text("正在扫描相册...") .font(.title.bold()) VStack(spacing: 8) { ProgressView(value: progress) .progressViewStyle(.linear) .frame(height: 12) .padding(.horizontal, 40) Text(progressText) .font(.headline.bold()) .foregroundColor(.blue) } Text("本批次照片总数:\(scanner.totalPhotos) 张") Text("当前正在扫描:第 \(scanner.currentIndex) 张") Text("已发现:\(scanner.foundCount) 张二维码占主体照片") .foregroundColor(.green) Button(action: scanner.stopScan) { Text("🛑 停止扫描") .padding() } .foregroundColor(.white) .background(Color.red) .cornerRadius(10) } else { Text("本批次扫描完成") .font(.title.bold()) Text("共找到 \(scanner.foundCount) 张二维码占主体照片") .font(.title) .foregroundColor(.green) Text(lastScanDateText) .font(.subheadline) .foregroundColor(Color(white: 0.2)) .multilineTextAlignment(.center) .padding(.horizontal) HStack(spacing: 20) { Button { scanner.currentIndex = 0 } label: { Text("返回") .frame(width: 100) .padding() } .background(Color.gray) .foregroundColor(.white) .cornerRadius(10) if scanner.foundCount > 0 { Button { showDetail = true } label: { Text("查看详情") .frame(width: 100) .padding() } .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) } else { Button { scanner.currentIndex = 0 scanner.startScan() } label: { Text("继续扫描") .frame(width: 100) .padding() } .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) } } } } } } .sheet(isPresented: $showDetail) { ResultDetailView( assets: scanner.foundAssets, showDetail: $showDetail, onDeleteCompleted: { scanner.currentIndex = 0 scanner.foundCount = 0 scanner.foundAssets.removeAll() scanner.isScanning = false } ) } .alert("提示", isPresented: $scanner.showNoMorePhotosAlert) { Button("确定", role: .cancel) {} } message: { Text("没有更多可扫描的照片了") } .alert("需要相册权限", isPresented: $scanner.showPermissionAlert) { Button("去设置") { UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) } Button("取消", role: .cancel) {} } message: { Text("请允许访问相册以扫描照片中的二维码") } } } // MARK: - 预览 struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/11 11:38:59

新代数控数据采集实战:从API接口到状态解析的完整指南

1. 新代数控数据采集系统概述 第一次接触新代数控系统时&#xff0c;我被它独特的架构设计吸引了。作为基于WinCE平台的工业级控制系统&#xff0c;新代数控在近几年推出的机型都标配了网口和API接口&#xff0c;这为远程监控和数据采集提供了硬件基础。在实际项目中&#xff0…

作者头像 李华
网站建设 2026/5/11 11:29:31

思源宋体完全指南:7款免费商用字体助你打造专业中文设计

思源宋体完全指南&#xff1a;7款免费商用字体助你打造专业中文设计 【免费下载链接】source-han-serif-ttf Source Han Serif TTF 项目地址: https://gitcode.com/gh_mirrors/so/source-han-serif-ttf 还在为中文排版找不到合适的免费字体而烦恼吗&#xff1f;今天我要…

作者头像 李华
网站建设 2026/5/11 11:23:00

三月七小助手:解放双手的崩坏星穹铁道全自动辅助工具终极指南

三月七小助手&#xff1a;解放双手的崩坏星穹铁道全自动辅助工具终极指南 【免费下载链接】March7thAssistant 崩坏&#xff1a;星穹铁道全自动 三月七小助手 项目地址: https://gitcode.com/gh_mirrors/ma/March7thAssistant 还在为《崩坏&#xff1a;星穹铁道》中繁琐…

作者头像 李华