Help us understand the problem. What is going on with this article?

CloudKitで画像を連携する - CKAsset編

前回のCloudKitで画像を連携するでUIImageをBinaryにしてCloudKitに保存する方法をご紹介しました。
ただこの方法の場合、1レコードのサイズを1MB以内にしなくてはならないという制限が付きます。
通信負荷も考慮するとできる限り1MB以内に収めるようにした方がいいのですが、アプリの仕様上どうしても1MBを超えてしまうこともあるでしょう。
その場合、今回紹介するAssetとして保存する方法が使えます。

環境:swift 4.2、Xcode 10.1

準備

まずCloudKit DashboardでRecordTypeの定義を行い、ここで画像を保存するフィールドのデータ型を「Asset」にします。
スクリーンショット 2019-03-17 20.16.24.png

続いてCloudKitを使ってみた Swift4版で紹介したように検索、ソートに使用するフィールドにINDEXを設定します。
スクリーンショット 2019-03-17 20.30.36.png

これで準備は完了です。

画像をCloudKitに保存

ではまず画像をCloudKitに保存してみます。

import UIKit
import CloudKit
-----------(中略)-----------

     /**
     Cloud KitにデータをINSERTする
     @param INSERTするデータ code:コード name:名称 costRate:原価率 salesPrice:売単価 image:画像
     */
    private func insertData(code:String,name:String,costRate:Double,salesPrice:Int,image:UIImage!){
        let ckDatabase = CKContainer.default().privateCloudDatabase

        //INSERTするデータを設定
        let ckRecord = CKRecord(recordType: "GoodsMaster2")
        ckRecord["code"] = code
        ckRecord["name"] = name
        ckRecord["costRate"] = costRate
        ckRecord["salesPrice"] = salesPrice

        if image != nil {
            let url = saveImageFile(fileName: code + ".png", image: image)
            if url == nil {
                return
            }
            let ckAsset = CKAsset(fileURL: url!)
            ckRecord["image"] = ckAsset
        }

        //データのINSERTを実行
        ckDatabase.save(ckRecord, completionHandler: { (ckRecords, error) in
            if error != nil {
                //INSERTがエラーになった場合
                print("\(String(describing: error?.localizedDescription))")
            }
        })
    }

     /**
     UIImageをファイルとして保存しURLを返す
     @param image:保存するUIImage fileName:ファイル名
     @return 保存したファイルのURL(保存に失敗した場合はnil)
     */
    private func saveImageFile(fileName:String,image:UIImage) -> URL!{
        let directoryName:String = NSHomeDirectory() + "/Library"
        let documentsURL = URL(fileURLWithPath: directoryName)
        let fileURL = documentsURL.appendingPathComponent(fileName)
        let pngImageData = image.pngData()
        let fileManager = FileManager.default

        do {
            if (!fileManager.fileExists(atPath: directoryName)){
                try fileManager.createDirectory(atPath: directoryName, withIntermediateDirectories: true, attributes: nil)
            }

            try pngImageData!.write(to: fileURL)

            return fileURL
        } catch let error{
            print("\(String(describing: error.localizedDescription))")
            return nil
        }
    }

手順

1.まずUIImageをファイルとして保存しそのファイルのURLを取得しています。
2.そのURLを引数としてCKAssetクラスを生成し、これをCKRecordの画像保存用のフィールドに設定しています。

let ckAsset = CKAsset(fileURL: url!)
ckRecord["image"] = ckAsset

3.最後にCKDatabaseのsaveメソッドの引数にCKRecordを渡して保存を実行しています。

これで画像がCloudKitのAssetに保存されました。

画像をCloudKitから取得

続いて今度は画像をCloudKitから取得してみます。

/**
 Cloud Kitからデータを検索する
 @param 検索条件 minSalesPrice:検索条件のsalesPriceの最小値 maxSalesPrice:検索条件のsalesPriceの最大値
*/
private func searchData(minSalesPrice:Int,maxSalesPrice:Int){
    let ckDatabase = CKContainer.default().privateCloudDatabase
    //検索条件指定
    let ckQuery = CKQuery(recordType: "GoodsMaster2", predicate: NSPredicate(format: "salesPrice >= %d and salesPrice <= %d", argumentArray: [minSalesPrice,maxSalesPrice]))

    //ソート条件指定
    ckQuery.sortDescriptors = [NSSortDescriptor(key: "salesPrice", ascending: false),NSSortDescriptor(key: "costRate", ascending: true)]

    //検索実行
    ckDatabase.perform(ckQuery, inZoneWith: nil, completionHandler: { (ckRecords, error) in
        if error != nil {
            //検索エラー
            print("\(String(describing: error?.localizedDescription))")
        }else{
            //検索成功
            self.goodsMasters.removeAll()
            for ckRecord in ckRecords!{
                var image:UIImage! = nil

                if (ckRecord["image"] != nil){
                    guard let ckAsset = ckRecord["image"] as? CKAsset else{
                        return
                    }
                    guard let imageData = NSData(contentsOf: ckAsset.fileURL) else {
                        return
                    }
                    image = UIImage(data: imageData as Data)
                }

                let goodsMaster = GoodsMaster(code: ckRecord["code"]!, name: ckRecord["name"]!, costPrice: ckRecord["costRate"]!, salesPrice: ckRecord["salesPrice"]!,image:image)
                self.goodsMasters.append(goodsMaster)
            }
            DispatchQueue.main.async {
                self.tableView.reloadData()
            }
        }
    })
}

手順

1.検索条件、ソート条件を設定してCKDatabaseのperformメソッドを実行して検索を行います。

2.検索結果が入ったCKRecordから値を取得する時に、Asset型のフィールドの場合はまずCKAssetに変換します。

guard let ckAsset = ckRecord["image"] as? CKAsset else{
    return
}

3.次にCKAssetからNSDataに変換します。

guard let imageData = NSData(contentsOf: ckAsset.fileURL) else {
    return
}

4.最後にNSDataからUIImageに変換します。

image = UIImage(data: imageData as Data)

これでCloudKitのAssetから画像を取得することができました。

サンプルコード

今回のサンプルコード全文です。
このサンプルコードは以下のGitHubで公開しています。
https://github.com/naosekig/CloudKitCKAssetSample

ViewController.swift
import UIKit
import CloudKit

class ViewController: UIViewController,UITableViewDelegate,UITableViewDataSource,UITextViewDelegate,UIImagePickerControllerDelegate,UINavigationControllerDelegate {
    private let labelSearch:UILabel = UILabel()
    private let labelMinSalesPrice:UILabel = UILabel()
    private let textMinSalesPrice:UITextView = UITextView()
    private let labelMaxSalesPrice:UILabel = UILabel()
    private let textMaxSalesPrice:UITextView = UITextView()
    private let buttonSearch:UIButton = UIButton()
    private let labelCode:UILabel = UILabel()
    private let textCode:UITextView = UITextView()
    private let labelName:UILabel = UILabel()
    private let textName:UITextView = UITextView()
    private let labelCostRate:UILabel = UILabel()
    private let textCostRate:UITextView = UITextView()
    private let labelSalesPrice:UILabel = UILabel()
    private let textSalesPrice:UITextView = UITextView()
    private let labelImage:UILabel = UILabel()
    private let imageView:UIImageView = UIImageView()
    private let buttonImage:UIButton = UIButton()
    private let buttonInsert:UIButton = UIButton()
    private let buttonUpdate:UIButton = UIButton()
    private let buttonDelete:UIButton = UIButton()
    private let tableView:UITableView = UITableView()
    private var goodsMasters:[GoodsMaster] = [GoodsMaster]()

    struct GoodsMaster {
        var code:String
        var name:String
        var costPrice:Double
        var salesPrice:Int
        var image:UIImage!
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        labelSearch.text = "検索条件[SalesPrice]"
        self.view.addSubview(labelSearch)

        labelMinSalesPrice.text = "最小値"
        self.view.addSubview(labelMinSalesPrice)

        designTextView(textView: textMinSalesPrice,keyboardType: .numberPad)
        self.view.addSubview(textMinSalesPrice)

        labelMaxSalesPrice.text = "最大値"
        self.view.addSubview(labelMaxSalesPrice)

        designTextView(textView: textMaxSalesPrice,keyboardType: .numberPad)
        self.view.addSubview(textMaxSalesPrice)
        buttonSearch.setTitle("検索", for: .normal)
        buttonSearch.addTarget(self, action: #selector(self.touchUpButtonSearch), for: .touchUpInside)
        designButton(button: buttonSearch)
        self.view.addSubview(buttonSearch)

        labelCode.text = "Code(コード)"
        self.view.addSubview(labelCode)
        designTextView(textView: textCode,keyboardType: .numberPad)
        self.view.addSubview(textCode)
        labelName.text = "Name(名称)"
        self.view.addSubview(labelName)
        designTextView(textView: textName,keyboardType: .default)
        self.view.addSubview(textName)
        labelCostRate.text = "CostRate(原価率)"
        self.view.addSubview(labelCostRate)
        designTextView(textView: textCostRate,keyboardType: .decimalPad)
        self.view.addSubview(textCostRate)
        labelSalesPrice.text = "SalesPrice(売単価)"
        self.view.addSubview(labelSalesPrice)
        designTextView(textView: textSalesPrice,keyboardType: .numberPad)
        self.view.addSubview(textSalesPrice)

        labelImage.text = "Image(画像)"
        self.view.addSubview(labelImage)
        imageView.contentMode = .scaleAspectFit
        self.view.addSubview(imageView)
        buttonImage.setTitle("Pictureから取得", for: .normal)
        buttonImage.addTarget(self, action: #selector(self.touchUpButtonPicture), for: .touchUpInside)
        designButton(button: buttonImage)
        self.view.addSubview(buttonImage)

        buttonInsert.setTitle("INSERT", for: .normal)
        buttonInsert.addTarget(self, action: #selector(self.touchUpButtonInsert), for: .touchUpInside)
        designButton(button: buttonInsert)
        self.view.addSubview(buttonInsert)

        buttonUpdate.setTitle("UPDATE", for: .normal)
        buttonUpdate.addTarget(self, action: #selector(self.touchUpButtonUpdate), for: .touchUpInside)
        designButton(button: buttonUpdate)
        self.view.addSubview(buttonUpdate)

        buttonDelete.setTitle("DELETE", for: .normal)
        buttonDelete.addTarget(self, action: #selector(self.touchUpButtonDelete), for: .touchUpInside)
        designButton(button: buttonDelete)
        self.view.addSubview(buttonDelete)

        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
        tableView.delegate = self
        tableView.dataSource = self
        self.view.addSubview(tableView)

        changeScreen()
    }

    private func designTextView(textView:UITextView,keyboardType:UIKeyboardType){
        textView.layer.cornerRadius = 10
        textView.layer.borderColor = UIColor.lightGray.cgColor
        textView.layer.borderWidth = 0.5
        textView.keyboardType = keyboardType
        textView.delegate = self
    }

    private func designButton(button:UIButton){
        button.layer.cornerRadius = 10
        button.layer.borderColor = UIColor.lightGray.cgColor
        button.layer.borderWidth = 0.5
        button.setTitleColor(UIColor.blue, for: .normal)
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {

        super.viewWillTransition(to: size, with: coordinator)

        coordinator.animate(
            alongsideTransition: nil,
            completion: {(UIViewControllerTransitionCoordinatorContext) in
                self.changeScreen()
        }
        )
    }

    private func changeScreen(){
        let screenSize: CGRect = UIScreen.main.bounds
        let widthValue = screenSize.width
        let heightValue = screenSize.height

        labelSearch.frame = CGRect(x: 5, y: 50, width: widthValue-120, height: 40)
        buttonSearch.frame = CGRect(x: widthValue-110, y: 50, width: 100, height: 40)
        labelMinSalesPrice.frame = CGRect(x: 5, y: 100, width: 50, height: 40)
        textMinSalesPrice.frame = CGRect(x: 60, y: 100, width: widthValue/2-65, height: 40)
        labelMaxSalesPrice.frame = CGRect(x: widthValue/2 + 5, y: 100, width: 50, height: 40)
        textMaxSalesPrice.frame = CGRect(x: widthValue/2 + 60, y: 100, width: widthValue/2-65, height: 40)

        labelCode.frame = CGRect(x: 5, y: 150, width: 150, height: 40)
        textCode.frame = CGRect(x: 160, y: 150, width: widthValue-165, height: 40)
        labelName.frame = CGRect(x: 5, y: 195, width: 150, height: 40)
        textName.frame = CGRect(x: 160, y: 195, width: widthValue-165, height: 40)
        labelCostRate.frame = CGRect(x: 5, y: 240, width: 150, height: 40)
        textCostRate.frame = CGRect(x: 160, y: 240, width: widthValue-165, height: 40)
        labelSalesPrice.frame = CGRect(x: 5, y: 285, width: 150, height: 40)
        textSalesPrice.frame = CGRect(x: 160, y: 285, width: widthValue-165, height: 40)
        labelImage.frame = CGRect(x: 5, y: 330, width: 150, height: 80)
        imageView.frame = CGRect(x: 160, y: 330, width: widthValue-320, height: 80)
        buttonImage.frame = CGRect(x: widthValue-155, y: 330, width: 150, height: 80)

        buttonInsert.frame = CGRect(x: 5, y: 415, width: widthValue/3-10, height: 40)
        buttonUpdate.frame = CGRect(x: widthValue/3+5, y: 415, width: widthValue/3-10, height: 40)
        buttonDelete.frame = CGRect(x: widthValue/3*2+5, y: 415, width: widthValue/3-10, height: 40)

        tableView.frame = CGRect(x: 5, y: 455, width: widthValue-10, height: heightValue-455)
    }

    /**
     Cloud KitにデータをINSERTする
     @param INSERTするデータ code:コード name:名称 costRate:原価率 salesPrice:売単価 image:画像
     */
    private func insertData(code:String,name:String,costRate:Double,salesPrice:Int,image:UIImage!){
        let ckDatabase = CKContainer.default().privateCloudDatabase

        //INSERTするデータを設定
        let ckRecord = CKRecord(recordType: "GoodsMaster2")
        ckRecord["code"] = code
        ckRecord["name"] = name
        ckRecord["costRate"] = costRate
        ckRecord["salesPrice"] = salesPrice

        if image != nil {
            let url = saveImageFile(fileName: code + ".png", image: image)
            if url == nil {
                return
            }
            let ckAsset = CKAsset(fileURL: url!)
            ckRecord["image"] = ckAsset
        }

        //データのINSERTを実行
        ckDatabase.save(ckRecord, completionHandler: { (ckRecords, error) in
            if error != nil {
                //INSERTがエラーになった場合
                print("\(String(describing: error?.localizedDescription))")
            }
        })
    }

    /**
     Cloud Kitからデータを検索する
     @param 検索条件 minSalesPrice:検索条件のsalesPriceの最小値 maxSalesPrice:検索条件のsalesPriceの最大値
     */
    private func searchData(minSalesPrice:Int,maxSalesPrice:Int){
        let ckDatabase = CKContainer.default().privateCloudDatabase
        //検索条件指定
        let ckQuery = CKQuery(recordType: "GoodsMaster2", predicate: NSPredicate(format: "salesPrice >= %d and salesPrice <= %d", argumentArray: [minSalesPrice,maxSalesPrice]))

        //ソート条件指定
        ckQuery.sortDescriptors = [NSSortDescriptor(key: "salesPrice", ascending: false),NSSortDescriptor(key: "costRate", ascending: true)]

        //検索実行
        ckDatabase.perform(ckQuery, inZoneWith: nil, completionHandler: { (ckRecords, error) in
            if error != nil {
                //検索エラー
                print("\(String(describing: error?.localizedDescription))")
            }else{
                //検索成功
                self.goodsMasters.removeAll()
                for ckRecord in ckRecords!{
                    var image:UIImage! = nil

                    if (ckRecord["image"] != nil){
                        guard let ckAsset = ckRecord["image"] as? CKAsset else{
                            return
                        }
                        guard let imageData = NSData(contentsOf: ckAsset.fileURL) else {
                            return
                        }
                        image = UIImage(data: imageData as Data)
                    }

                    let goodsMaster = GoodsMaster(code: ckRecord["code"]!, name: ckRecord["name"]!, costPrice: ckRecord["costRate"]!, salesPrice: ckRecord["salesPrice"]!,image:image)
                    self.goodsMasters.append(goodsMaster)
                }
                DispatchQueue.main.async {
                    self.tableView.reloadData()
                }
            }
        })
    }

    /**
     指定した条件でCloud KitのデータをUPDATEする
     @param whereCode:更新対象のコード updateName:名称の更新値 updateCostRate:原価率の更新値 updateSalesPrice:売単価の更新値 updateImage:画像の更新値
     */
    private func updateData(whereCode:String,updateName:String,updateCostRate:Double,updateSalesPrice:Int,updateImage:UIImage!){
        let ckDatabase = CKContainer.default().privateCloudDatabase

        //1.更新対象のレコードを検索する
        let ckQuery = CKQuery(recordType: "GoodsMaster2", predicate: NSPredicate(format: "code == %@", argumentArray: [whereCode]))
        ckDatabase.perform(ckQuery, inZoneWith: nil, completionHandler: { (ckRecords, error) in
            if error != nil {
                print("\(String(describing: error?.localizedDescription))")
            }else{
                //2.検索したレコードの値をUPDATEする
                for ckRecord in ckRecords!{
                    ckRecord["name"] = updateName
                    ckRecord["costRate"] = updateCostRate
                    ckRecord["salesPrice"] = updateSalesPrice
                    if (updateImage != nil){
                        let url = self.saveImageFile(fileName: ckRecord["code"]! + ".png", image: updateImage)
                        if url == nil {
                            return
                        }
                        let ckAsset = CKAsset(fileURL: url!)
                        ckRecord["image"] = ckAsset
                    }

                    ckDatabase.save(ckRecord, completionHandler: { (ckRecord, error) in
                        if error != nil {
                            print("\(String(describing: error?.localizedDescription))")
                        }
                    })
                }
            }
        })
    }

    /**
     指定した条件でCloud KitのデータをDELETEする
     @param whereCode:削除対象のCode
     */
    private func deleteData(whereCode:String){
        let ckDatabase = CKContainer.default().privateCloudDatabase

        //1.削除対象のレコードを検索する
        let ckQuery = CKQuery(recordType: "GoodsMaster2", predicate: NSPredicate(format: "code == %@", argumentArray: [whereCode]))
        ckDatabase.perform(ckQuery, inZoneWith: nil, completionHandler: { (ckRecords, error) in
            if error != nil {
                print("\(String(describing: error?.localizedDescription))")
            }else{
                //2.検索したレコードを削除する
                for ckRecord in ckRecords!{
                    ckDatabase.delete(withRecordID: ckRecord.recordID, completionHandler: { (recordId, error) in
                        if error != nil {
                            print("\(String(describing: error?.localizedDescription))")
                        }
                    })
                }
            }
        })
    }

    /**
     UIImageをファイルとして保存しURLを返す
     @param image:保存するUIImage fileName:ファイル名
     @return 保存したファイルのURL(保存に失敗した場合はnil)
     */
    private func saveImageFile(fileName:String,image:UIImage) -> URL!{
        let directoryName:String = NSHomeDirectory() + "/Library"
        let documentsURL = URL(fileURLWithPath: directoryName)
        let fileURL = documentsURL.appendingPathComponent(fileName)
        let pngImageData = image.pngData()
        let fileManager = FileManager.default

        do {
            if (!fileManager.fileExists(atPath: directoryName)){
                try fileManager.createDirectory(atPath: directoryName, withIntermediateDirectories: true, attributes: nil)
            }

            try pngImageData!.write(to: fileURL)

            return fileURL
        } catch let error{
            print("\(String(describing: error.localizedDescription))")
            return nil
        }
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return goodsMasters.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let index:Int = indexPath.row
        var cell:UITableViewCell!
        cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel!.text = goodsMasters[index].code + " " + goodsMasters[index].name
        cell.textLabel!.font = UIFont.systemFont(ofSize: 16)
        cell.textLabel!.adjustsFontSizeToFitWidth = true
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let index:Int = indexPath.row
        textCode.text = goodsMasters[index].code
        textName.text = goodsMasters[index].name
        textCostRate.text = goodsMasters[index].costPrice.description
        textSalesPrice.text = goodsMasters[index].salesPrice.description
        imageView.image = goodsMasters[index].image
    }

    @objc func touchUpButtonSearch(){
        self.view.endEditing(true)

        var minSalesPriceString = textMinSalesPrice.text!
        if (minSalesPriceString.count == 0){
            minSalesPriceString = "0"
        }
        var maxSalesPriceString = textMaxSalesPrice.text!
        if (maxSalesPriceString.count == 0){
            maxSalesPriceString = "999999999999"
        }

        searchData(minSalesPrice: Int(minSalesPriceString)!, maxSalesPrice: Int(maxSalesPriceString)!)
    }

    @objc func touchUpButtonInsert(){
        self.view.endEditing(true)

        let codeString = textCode.text!
        let nameString = textName.text!
        let costRateString = textCostRate.text!
        let salesPriceString = textSalesPrice.text!

        insertData(code: codeString, name: nameString, costRate: Double(costRateString)!, salesPrice: Int(salesPriceString)!,image:imageView.image)
    }

    @objc func touchUpButtonUpdate(){
        self.view.endEditing(true)

        let codeString = textCode.text!
        let nameString = textName.text!
        let costRateString = textCostRate.text!
        let salesPriceString = textSalesPrice.text!

        updateData(whereCode: codeString, updateName: nameString, updateCostRate: Double(costRateString)!, updateSalesPrice: Int(salesPriceString)!,updateImage: imageView.image)
    }

    @objc func touchUpButtonDelete(){
        self.view.endEditing(true)

        let codeString = textCode.text!

        deleteData(whereCode: codeString)
    }

    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        if (text == "\n"){
            textView.resignFirstResponder()
            return false
        }
        return true
    }

    @objc func touchUpButtonPicture(){
        openPicker()
    }

    @objc func openPicker(){
        if !UIImagePickerController.isSourceTypeAvailable(UIImagePickerController.SourceType.photoLibrary){
            return
        }

        let picker = UIImagePickerController()
        picker.sourceType = UIImagePickerController.SourceType.photoLibrary
        picker.delegate = self

        self.present(picker,animated:false,completion:nil)
    }

    func imagePickerController(_ picker:UIImagePickerController,didFinishPickingMediaWithInfo info:[UIImagePickerController.InfoKey:Any]){

        picker.presentingViewController?.dismiss(animated: false, completion: nil)

        let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage

        let widthValue:CGFloat = image!.size.width
        let heightValue:CGFloat = image!.size.height
        let scaleValue:CGFloat = 860/widthValue

        let size = CGSize(width: widthValue*scaleValue, height: heightValue*scaleValue)
        UIGraphicsBeginImageContext(size)
        image!.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
        let resizeImage:UIImage! = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()

        imageView.image = resizeImage
    }
}

参考文献

CKAsset - CloudKit | Apple Developer Document
How to properly send an image to CloudKit as CKAsset? - Stack Overflow
How to receive an image from cloudkit? - Stack Overflow

関連記事

CloudKitを使ってみた Swift4版
CloudKitで画像を連携する

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした