紧抱豆包大腿,开发了一个iOS实用小工具(二维码照片清理app)
手机相册里存了很多随身码核算码啥的图片身在上海经历过20202022年的都懂。一直没有耐心好好去清理。但总惦记着这个事情想写个工具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对应界面提示文字可以根据自己偏好调整KeyTypeValuePrivacy - Photo Library Usage DescriptionString需要访问相册以扫描二维码照片Privacy - Photo Library Additions Usage DescriptionString需要访问相册以删除照片最后作为一个App在桌面上得有一个Icon随意用AI生成了一张图标1024×1024的PNG图片。左侧工程树形导航栏里选Assets中部导航栏选AppIcon右侧左上角有个Any Apperance把图片从Finder里拖进来就OK了。以下附上主文件Swift源码其他工程文件就不必要了。我自己没有买Apple的开发者订阅毕竟一年要99美元我又不是职业iOS开发程序猿如果哪位土豪看官或者热心人想让我把这个App上架可以赞助或者帮我众筹这笔经费。亲们如果有任何问题或者要求想跟我交流欢迎跟我联系eMailzongchaosina.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: SetInt [] 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: BindingBool, 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(第 \(currentPage1)/\(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() } }