SwiftUI应用开屏广告界面项目(二)

  • 需求
  • 存储方案说明
  • 源码
  • ContentView.swift
  • Persistence.swift
  • CoreData
  • 不足


SwiftUI应用开屏广告界面项目(一)

需求

在(一)的基础上,添加以下需求:
远端同时提供活动页图片需显示的次数,图片显示次数到达限制之后不再显示;
图片URL与显示次数均以json格式传输;
当有多个开屏活动存在时,选择最新的一个展示。

存储方案说明

根据此次需求来看,工程需要用到数据保存功能。在Swift中数据存储通常采用Sqlite或者CoreData两种方式;由于我的数据库老师因为某些人格魅力上的问题导致整个班对数据库的操作全靠自学,我对Sqlite并不熟悉;而看到CoreData是Xcode自带的并且(据说)操作十分简单,所以这里我选择使用CoreData。

老规矩,先在精彩的互联网上搜索有无swift的CoreData教程;发现在我写下这篇文章之前发布的教程中都无一例外地提到了AppDelegate.swift这个东西。

我看了眼我的工程目录。

swift 优秀的MVVM swiftui项目_生命周期

nm逗我呢!哪来的AppDelegate.swift这个文件啊!还浪费我几块钱去看付费专栏教程!艹

经过多次新建工程之后,我发现了问题所在。

swift 优秀的MVVM swiftui项目_json_02


在新建工程时,Use Core Data这个选项肯定是要勾选上的,但是在生命周期(Life Cycle)那一栏中,选项不同,调用CoreData的方式也不同。

当选择UIKit App Delegate时,工程中就会有AppDelegate.swift这个文件,对CoreData的操作照着目前绝大多数的博客/教程走就行;

如果你们和我选的同样是SwiftUI App时,工程中就不会生成AppDelegate.swift这个文件;对CoreData的操作范例方法会生成在ContentView中,操作的实体为CoreData中默认生成的Item,包含一个timestamp日期字段。

不得不吐槽一句各位大佬的博客虽写的比我好,但是还是希望大佬们能够提一句这个生命周期的问题…

这两个生命周期的具体区别我暂时没有去了解。

源码

这里我只放出来了在新建工程以及(一)的基础上修改了的文件。

ContentView.swift

//
//  ContentView.swift
//  core1
//
//  Created by WMIII on 2021/4/3.
//

import SwiftUI
import UIKit
import Combine
import CoreData

class TimeHelp {
    var canceller: AnyCancellable?
        
    //每次都新建一个计时器
    func start(receiveValue: @escaping (() -> Void)) {
        let timerPublisher = Timer
            .publish(every: 1, on: .main, in: .common)
            .autoconnect()
        
        self.canceller = timerPublisher.sink { date in
            receiveValue()
        }
    }
    
    //暂停销毁计时器
    func stop() {
        canceller?.cancel()
        canceller = nil
    }
}


struct Advertisement: Codable {
    // var id = UUID()
    var picUrl: String
    var showTime: Int
}


struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    
    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Ads.picUrl, ascending: true)],
        animation: .default)
    // 从CoreData中读取Ads的数据
    private var ads: FetchedResults<Ads>
    
    @State private var remoteImage :UIImage? = nil
    @State var isPresented = false
    let placeholderOne = UIImage(named: "Image1")
    
    let timer = Timer.publish(every: 1, on: .main, in: .common)
    
    @State private var second = 3
    private let timeHelper = TimeHelp()
    @State private var end = true
    
    @State var adsJson: [Advertisement] = []  // 这里去掉@State会发生很奇怪的事
    
    var body: some View {
        ZStack
        {
            Button("跳过 \(second)"){
                self.isPresented = true
            }
            .position(x: UIScreen.main.bounds.width - 45, y: 10.0)
            .onAppear()
            {
                // getAdJson()
                // print(ads.count)
                
                guard self.end else {return}
                self.end = false
                self.second = 3
                self.timeHelper.start {
                    // print(second)
                    if self.second > 1 {
                        _ = self.second -= 1
                        
                    }else{
                        //暂停
                        self.end = true
                        self.timeHelper.stop()
                        self.isPresented = true
                    }
                }
            }
            .fullScreenCover(isPresented: $isPresented) {
                print("消失")
            } content: {
                DetailView(message: "I'm missing you")
            }
            
            Image(uiImage: self.remoteImage ?? placeholderOne!)
            // Image(uiImage: self.placeholderOne!)
                .resizable()
                .scaledToFit()
                // .aspectRatio(contentMode: .fill)
                .onAppear(perform: fetchRemoteImg)
        }
    }
    
    // 获取远端图片
    func fetchRemoteImg()
    {
        getAdJson()
        let length = ads.count
        if length != 0
        {
        	// 由于每次调用Array中的最后一个图片URL,可以保证每次显示的图片都是最新的活动
            let showad = ads[length - 1]
            guard let url = URL(string: showad.picUrl!) else {return}
            
            URLSession.shared.dataTask(with: url)
            {
                (data, response, error) in
                if let img = UIImage(data: data!)
                {
                    self.remoteImage = img
                    ads[length - 1].showTime -= 1
                    do {
                        try viewContext.save()
                    } catch {
                        // Replace this implementation with code to handle the error appropriately.
                        // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                        let nsError = error as NSError
                        fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
                    }
                    
                    if ads[length - 1].showTime == 0 {
                        deleteItems(offsets: [length - 1])
                    }
                }
                else
                {
                    print(error ?? "1")
                }
            }
            .resume()
        }
        else
        {
            return
        }
    }
    
    
    // 向服务器请求json数据并接收
    func getAdJson()
    {
        // 测试用URL地址
        let urlAddress = "http://127.0.0.1:8000/api/getjson"
        
        guard let adurl = URL(string: urlAddress) else {return}
        URLSession.shared.dataTask(with: adurl) {
            (data, response, error) in
            do {
                if let d = data
                {
                    let jItem = try JSONDecoder().decode(Advertisement.self, from: d)
                    DispatchQueue.main.async {
                        addItem(adjson: jItem)
                        // print(jItem.picUrl)
                        // print(jItem.showTime)
                    }
                }
                else
                {
                    print("no data.")
                }
            }
            catch
            {
                print("error")
            }
        }.resume()
    }

    
    // 向CoreData中的ads实体数组添加数据
    private func addItem(adjson: Advertisement) {
        withAnimation {
            let newAd = Ads(context: viewContext)
            newAd.picUrl = adjson.picUrl
            newAd.showTime = Int32(adjson.showTime)

            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
    

	// 将ads数组中指定位置的数据删除
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            offsets.map { ads[$0] }.forEach(viewContext.delete)

            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
}

struct DetailView: View{
    let message: String
    
    var body: some View {
        VStack
        {
            Text(message)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
    }
}

Advertisement结构体保存图片URL和剩余显示次数,以满足需求;

新增的一些奇奇怪怪的对CoreData进行操作的东西都是基于生成的代码修改的;把所有的Item实体换成Ads实体就行。

getAdJson()方法中,URL地址http://127.0.0.1:8000/是我在本地搭建的Django测试服务器,其下路径/api/getjson返回一份包含图片URL地址和显示次数的json数据,内容如下:

{
    "picUrl": "https://store.storeimages.cdn-apple.com/8756/as-images.apple.com/is/mbp-spacegray-select-202011_GEO_CN?wid=904&hei=840&fmt=jpeg&qlt=80&.v=1613672857000",
    "showTime": 3
}

搭建的教程博客后面再放上来(咕)。
Django服务器参考:https://www.jianshu.com/p/2d60bf3faf37

Persistence.swift

这个文件我只是单纯的注释掉了for循环那段,并没有做出其它改动。

//
//  Persistence.swift
//  core1
//
//  Created by WMIII on 2021/4/3.
//

import CoreData

struct PersistenceController {
    static let shared = PersistenceController()

    static var preview: PersistenceController = {
        let result = PersistenceController(inMemory: true)
        let viewContext = result.container.viewContext
        
        /*
        for _ in 0..<10 {
            let newItem = Item(context: viewContext)
            newItem.timestamp = Date()
        }
        do {
            try viewContext.save()
        } catch {
            // Replace this implementation with code to handle the error appropriately.
            // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
            let nsError = error as NSError
            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
        }*/
        return result
    }()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "core1")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.

                /*
                Typical reasons for an error here include:
                * The parent directory does not exist, cannot be created, or disallows writing.
                * The persistent store is not accessible, due to permissions or data protection when the device is locked.
                * The device is out of space.
                * The store could not be migrated to the current model version.
                Check the error message to determine what the actual problem was.
                */
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
    }
}

CoreData

swift 优秀的MVVM swiftui项目_swift_03


这个文件是工程中生成的与工程同名的.xcdatamodeld文件;在这里添加实体的方法很简单,点击左下角的Add Entity就行。

这里我添加了一个名为Ads的实体,其下有两个字段:保存图片URL地址的picUrl和剩余需显示次数showTime,与我的ContentView.swift中定义的Advertisement结构体相同。

注意,在修改完CoreData之后一定要按下Command+B进行编译,否则修改不会生效!(我因为这个问题报了半天的错)(半天指真实时间的半天)

不足

在我的工程中,第一次打开应用的时候是不会显示活动的,只有第二次才会显示。(小问题)

一次只会接收单份json数据,而且是在打开应用时接收;不过我觉得这是小问题,毕竟显示活动也只是在打开应用的时候显示,而且每次只显示一个活动图片。

若后端发给前端两份相同的json数据,或者发送了一个URL与目前所保存的某个实体相同的数据时,前端并没有判断是将这两份数据合并(显示次数相加)还是忽略其中一份,而是将其单独当成两个不同的活动处理;所以我的工程默认正常工作环境是后端只会发送一次活动数据,显然对后端的条件过于苛刻。(后面再改)