Compose Multiplatform+kotlin Multiplatfrom第二弹

前言

上篇文章kmp实战处理基础业务,更多都是实现shared模块下的一套代码多平台共用的业务功能,但是遇到平台特性不同依然摆脱不了原生实现。

本文罗列下双端都要实现的功能:

  • 文件系统统一管理,Android是有分内部存储私有,外部储存有公共目录和私有目录,私有其他应用无法查看,内部存储连手机文件管理也看不到,我们手机的文件肯定存在用户需要对文件进行分享、复制等操作,所以要归类好文件目录。iOS端每次保存的后的路径文本是被沙盒隔离的,就是你下载一图片保存后,你记录下他的保存路径,下次你用完整的路径查不到,或者说你规定下载一个文件目录,每次下载到里面,不同时期路径中的文件目录会自动改变,当然也能处理,后面细谈。
  • 多端生命周期监听,主要用来监听应用退出和进入,业务逻辑需要放到shared一处修改多端共用。
  • 兼容长图的水印图片生成保存到图册,根据媒体文件的路径保存到图册,支持gif,图片png,jpg,jepg,视频mov,mp4,m4v。
  • 文档预览功能,Android系统不支持直接预览文档,类似doc/xls/ppt等,所以Android用的最新付费腾讯云浏览服务sdk,iOS端是支持系统预览文档,这里是通过ktor下载文件到本地目录,然后通过路径作为参数打开预览窗口。
  • ktor队列下载文件,支持单线程的断点下载,可暂停和恢复下载,可监控下载进度和下载速度,回调文件保存地址,这里下载的文件会根据格式自动先下载到内部缓存目录,如果是图片或需要添加水印的图片会重绘图后生成到图册。

文件目录管理

文件目录在iOS和Android是不同,如果在kotlin multiplatform compose(kmp)项目下,shared模块里是没有公共的文件类,我们的Java.io.File是Android特有的,也没FileInputStream去读写文件,一开始我看coil3源码如何保存图片保存,我看内部使用okio的FileSystem的处理,但是只能内部私有目录。

shared模块:

//在shared各业务调用创建文件
expect fun createPlatformFile(filePath: String)
//所有要处理文件前都要先创建文件夹,才能创文件
expect fun createPlatformRootDir()
//缓存图片、视频文件的内部目录
expect fun getDevDCIM(userId: Long, isCopy: Boolean = false): String
//Android端可对外查看的目录,iOS只有内部
expect fun getGlobalDirPath(userId: Long): String

 fun printLogW(msg: String, tag: String) {
    if (true) {
        var log = msg
        val segmentSize = 3 * 1024
        val length = log.length
        if (length <= segmentSize) {
            Logger.w(tag) { msg }
        } else {
            while (log.length > segmentSize) {
                val logContent = log.substring(0, segmentSize)
                log = log.replace(logContent, "")
              //  Logger.w(tag) { "\n$log" }
               println("\n$log")
            }
        }
    }
}

object GlobalCode {
  //文件和目录创建是不一样的,这里Path其实就是String
    fun createFile(file: Path, mustCreate: Boolean = false) {
        if (mustCreate) {
            FileSystem.SYSTEM.sink(file, mustCreate).close()
        } else {
            FileSystem.SYSTEM.appendingSink(file).close()
        }
    }

    fun createDirectory(dir: Path) {
        if (!FileSystem.SYSTEM.exists(dir)) {
            FileSystem.SYSTEM.createDirectories(dir, true)
        }
    }
    
fun getFileCacheDir(): Path {
        return FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "ark_file/${
            getCacheLong(
                KmmConfig.USER_ID,
                0
            )
        }"
    }

    fun getMediaCacheDir(): Path {
        return FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "ark_media/${
            getCacheLong(
                KmmConfig.USER_ID,
                0
            )
        }"
    }

    //私有目录,手机也查看不到
    fun getDownloadDir(): Path {
        return FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "ark_download/${
            getCacheLong(
                KmmConfig.USER_ID,
                0
            )
        }"
    }

    //这里需要平台特性,图片和文档目录区分,高版本安卓系统又无法直接下载到图册
    fun getGlobalDCIMPath(
        url: String?,
        userId: Long = 0,
        isCopy: Boolean = false
    ): String {
        if (canPreview2DCIM(getFileTypeByUrl(url + "").toUpperCase())) {
            return getDevDCIM(userId, isCopy) //图册目录、图片的缓存目录
        } else {
            return getGlobalDirPath(userId) //公共目录
        }
    }

    //下载到相册的文件类型
    fun canPreview2DCIM(fileType: String?): Boolean {
        return when (fileType) {
            "JPG", "PNG", "GIF", "JPEG",
            "MP4", "M4V", "MOV",
            -> {
                true
            }

            else -> {
                false
            }
        }
    }

fun getFileTypeByUrl(url:String):String{
	//这里fileType 就是 PNG , JEPG ,简单处理,我的是跟业务url规则有关,自己修改
	if(url.contains(".png")){ return "PNG" }
	if(url.contains(".jpeg")){ return "JPEG"}
	return "JPG"
}

  //是否可以预览
    fun canPreviewFile(fileType: String?): String? {
        fileType?.let {
            if (it.contains("JPG") || it.contains("PNG") || it.contains("GIF") || it.contains("JPEG") ||
                it.contains("MP4") || it.contains("M4V") || it.contains("MOV") ||
                it.contains("PPTX") || it.contains("PPT") || it.contains("DOC") || it.contains("DOCX") || it.contains(
                    "PDF"
                ) || it.contains("TXT") ||
                it.contains("XLS") || it.contains("XLSX") || it.contains("DWG")
            ) {
                return fileType
            }
        }
        return null
    }
    
  fun fileExist(filePath: String?): Boolean {
        if (filePath == null) return false
        return FileSystem.SYSTEM.exists(filePath.toPath())
    }
    
	fun getCacheLong():Long{ return 0} //这是我内部业务缓存API,随机放吧,用来隔离不同账号数据的
}

androidMain模块

 class MainApplication : Application() {

companion object {
        lateinit var appContext: Context
      
        val SAVE_DIR = Environment.DIRECTORY_DCIM

        /****私有目录,卸载被删除*****/
        @JvmStatic
        fun getAppCacheDir(): File? {
            return appContext.getExternalFilesDir("ark_cache")
        }

        //预览文件(图片、视频、文档)-都是要先下载好,先放私有目录,使用时复制到共有,不同账号数据隔离
        @JvmStatic
        fun getUserCacheDir(): File? {
            return appContext.getExternalFilesDir("file_cache")
        }

        @JvmStatic
        fun getImageCacheDir(): File? {
            return appContext.getExternalFilesDir("ark_img")
        }

        //如果每次同链接都下载都这,然后要下载时复制到共有目录
        @JvmStatic
        fun getDownLoadDir(): File {
//            return appContext.getExternalFilesDir("ark_downLoad") //外部存储的私有目录 还是可以对外看见
            return File(appContext.filesDir.absolutePath + File.separator + "ark_download") //内部存储,手机也无法看见
        }

        @JvmStatic
        fun getUserDownLoadDir(userId: Long=getCacheLong(KmmConfig.USER_ID,0)): File? {
            return appContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS + "/" + userId)
        }

        /****公有目录,要权限  目前所有的手动下载都到这里,允许重复下载****/
        @JvmStatic
        fun getGlobalDir(userId: Long = getCacheLong(KmmConfig.USER_ID,0)): String {
            return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {//android10开始分区存储
                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath + File.separator + "ark_download" + File.separator + userId + File.separator
            } else {
                Environment.getExternalStorageDirectory().absolutePath + File.separator + "ark_download" + File.separator + userId + File.separator
            }
        }

        /** 文件到图册*/
        @JvmStatic
        fun getDevDCIM(userid: Long = getCacheLong(KmmConfig.USER_ID,0), isCopy: Boolean = false): String {
            return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                if (isCopy) {
//                    if (isHarmonyOs())  //临时缓存文件夹,Android10后是先下载到外部再复制到图册,鸿蒙必须是应用内部再复制,这里特意分开不然不容易发现
                    getUserDownLoadDir(userid)?.absolutePath + ""
                } else {
                    Environment.getExternalStorageDirectory().absolutePath + File.separator + SAVE_DIR + File.separator + "ark_download" + File.separator + userid + File.separator
                }
            } else {
                Environment.getExternalStoragePublicDirectory(SAVE_DIR).absolutePath + File.separator + "ark_download" + File.separator + userid + File.separator
            }
        }
    }
}

actual fun createPlatformFile(filePath: String) {
    createPlatformRootDir()
    createFile(filePath)
}

private fun createFile(fileName: String, dir: String?): String {
        var file: File
        if (dir.isNullOrEmpty()) {
            file = File(fileName)
        } else {
            file = File(dir, fileName)
            printLogW("create??$file")
        }
        if (!file.exists()) {
            file.createNewFile()
        }
        return file.absolutePath
    }

actual fun createPlatformRootDir() {
    //指定在相册内外部目录  /storage/emulated/0/DCIM/ark_download/3515
    //Android10 bug 不加这个requestLegacyExternalStorage,无法创建
    val DCIMDir = MainApplication.getDevDCIM()
    val d1 = File(DCIMDir).apply { printLogW(this.absolutePath) }.mkdirs()
    //普通公共目录      /storage/emulated/0/Download/ark_download/3515
    val globalDir = MainApplication.getGlobalDir()
    val d2 = File(globalDir).apply { printLogW(this.absolutePath) }.mkdirs()
    //内部目录,手机看不到    /data/user/0/com.lyentech.ark/files/ark_download
    val downloadDir = MainApplication.getDownLoadDir()
    val d3 = downloadDir.apply { printLogW(this.absolutePath) }.mkdirs()
    //Android10后下载的图片不能直接到图册,中转文件夹再复制到图册
    //  /storage/emulated/0/Android/data/com.lyentech.ark/files/Download/3515
    val DCIMCacheDir = MainApplication.getUserDownLoadDir()
    val d4 = DCIMCacheDir!!.apply { printLogW(this.absolutePath) }.mkdirs()

//    printLogW("exists>$d1 $d2 $d3 $d4")
    GlobalCode.createDirectory(GlobalCode.getFileCacheDir())
    GlobalCode.createDirectory(GlobalCode.getMediaCacheDir())
    GlobalCode.createDirectory(GlobalCode.getDownloadDir())

//    printLogW("exists>${File(DCIMDir).exists()} ${File(globalDir)} ${downloadDir.exists()} ${DCIMCacheDir.exists()}")
}

actual fun getDevDCIM(userId: Long, isCopy: Boolean): String {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        if (isCopy) {
            MainApplication.getUserDownLoadDir(userId)?.absolutePath + ""
        } else {
            Environment.getExternalStorageDirectory().absolutePath + File.separator +
                    MainApplication.SAVE_DIR + File.separator + "ark_download" +
                    File.separator + userId + File.separator
        }
    } else {
        Environment.getExternalStoragePublicDirectory(MainApplication.SAVE_DIR).absolutePath +
                File.separator + "ark_download" + File.separator + userId + File.separator
    }
}

//公共目录
actual fun getGlobalDirPath(userId: Long): String {
    return MainApplication.getGlobalDir(userId)
}

iosMain模块

//iOS的保存目录是动态运算时路径自动变化的,但是系统提供API去获取
fun getIOSRootDir(): String {
    val paths =
        NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true) as List<*>
    return paths.first() as String
}

actual fun createPlatformFile(filePath: String) {
//    printLogW("create>${getIOSRootDir()} $filePath")
    ///var/mobile/Containers/Data/Application/F4A3896C-A93C-459D-9C38-5F95132113D8/Documents/3515/6d31c097a5dad90cd957d3a0a3b33f40_1678782605804.xls.tmp
    createDirectory(getDevDCIM(getCacheLong(KmmConfig.USER_ID)).toPath())
    createDirectory(getGlobalDirPath(getCacheLong(KmmConfig.USER_ID)).toPath())
   createFile(filePath.toPath(), false)

//    createPlatformRootDir()
//    GlobalCode.createFile(filePath.toPath(), false)

}

//预览文档的目录不关联userId,但是水印图片的要,下载的资料要
//iOS的下载使用要先创建内部目录
actual fun createPlatformRootDir() {
    //iOS暂时没发现有外部存储目录
    GlobalCode.createDirectory(GlobalCode.getFileCacheDir())
    GlobalCode.createDirectory(GlobalCode.getMediaCacheDir())
    GlobalCode.createDirectory(GlobalCode.getDownloadDir())
}

actual fun getDevDCIM(userId: Long, isCopy: Boolean): String {
    return getIOSRootDir() + "/" + userId
}

actual fun getGlobalDirPath(userId: Long): String {
    return getIOSRootDir() + "/" + userId
}

AndroidApp模块下

//清单文件AndroidManifest.xml
<application>
// android:requestLegacyExternalStorage="true" //自己配吧,都是原生的,还有权限
// android:networkSecurityConfig="@xml/network_security_config",其他...
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.fileProvider"
            android:exported="false"
            android:grantUriPermissions="true"
            tools:replace="name,authorities,exported,grantUriPermissions">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths"
                tools:replace="name,resource" />
        </provider>
    </application>

生命周期监听

kmp框架项目,其实就一个Activity,内部用路由构建窗口跳转,iOS其实就是UIViewController,看代码调用启动是iOSApp.swifit内的ContentView(),ContentView.swift内声明ComposeView输出 UIViewController{} ,ContentView内又包括ComposeView,这都是自动生成的。平台特性,原生API实现。
生命周期效果图

shared模块

//创建 AppLifecycleObserver.kt
package com.your.common

interface AppLifecycleObserver {
    fun onStart()
    fun onStop()
    fun onResume()
    fun onPause()
}

androidMain模块

open class ActivityLifecycleCallbacksImpl : Application.ActivityLifecycleCallbacks {

    override fun onActivityCreated(activity: Activity, p1: Bundle?) {
//        printD("onActivityCreated--$activity")
    }

    override fun onActivityStarted(activity: Activity) {
//        printD("onActivityStarted--$activity $activityTopCount")
        //数值记录从0到1说明是从后台切到前台
    }

    override fun onActivityResumed(activity: Activity) {
        printLogW("onActivityResumed--$activity")
        
    }

    override fun onActivityPaused(activity: Activity) {
        printLogW("onActivityPaused--$activity")
        
    }

    override fun onActivityStopped(activity: Activity) {
//        printD("onActivityStopped--$activity")

    }

    override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) {
    }

    override fun onActivityDestroyed(activity: Activity) {
//        printD("onActivityDestroyed--$activity")
    }
}

 class MainApplication :Application(){
override fun onCreate(){
 registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacksImpl() {
            override fun onActivityResumed(activity: Activity) {
                super.onActivityResumed(activity)
            }
        })
        }
}

iosMain模块

这里备注个坑,因为文件我们都是在Android Studio里创建编写,在编译iOS代码时是去xCode,xCode编译可以选择文件是否参与编译,一开始不知道搞半天😆😆一直报错,当然中间也是解决很多bug,里面涉及比较多的语法问题,因为我们是kotlin的接口要被swift调用,而且kotlin包装了很多swift的API,但是写的时候又报错,最常见就是宽高,如 image.size.useContents { CGSizeMake(width, height)} ,记住useContents{}这大用。

//创建 AppLifecycleObserverImpl.kt
package com.your.pk

import com.lyentech.ark.common.AppLifecycleObserver
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSSelectorFromString
import platform.UIKit.UIApplication

@OptIn(ExperimentalForeignApi::class)
class AppLifecycleObserverImpl : AppLifecycleObserver {

    init {
        val notificationCenter = NSNotificationCenter.defaultCenter
        notificationCenter.addObserver(
            observer = this,
            selector = NSSelectorFromString("onAppEnterForeground"),
            name = UIApplication.debugDescription(),
            `object` = null
        )
        notificationCenter.addObserver(
            observer = this,
            selector = NSSelectorFromString("onAppEnterBackground"),
            name = UIApplication.debugDescription(),
            `object` = null
        )
    }

    override fun onStart() {
        printLogW("ios-start>>")
    }

    override fun onStop() {//成功看到这里啦!!!
        printLogW("ios-onStop>>")
    }

    override fun onResume() { //成功看到这里啦!!!
        printLogW("ios-onResume>>")
    }

    override fun onPause() {
        printLogW("ios-onPause>>")
    }

}
//修改iOSApp.swift
import SwiftUI
import shared
//iOS App用 Xcode编译,这里记录每次可编日期2024/6/5
//这里是注解出iOS的入口页面 main main main
//界面都封装在contentView ,它里又封装controller,它里又 封装App() 这是具体的控件ui
@main
struct iOSApp: App { //这个App是iOS 自己的库类

//生命周期
 @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

@Environment(\.scenePhase) private var scenePhase

	var body: some Scene {
		WindowGroup {
			ContentView()
		}
.onChange(of: scenePhase){ newScene in
         	switch newScene {
            case .active:
//                print("应用已变为活跃状态")
 LifecycleHandler.shared.notifyResume()
            case .inactive:
//                print("应用将进入非活跃状态")
 LifecycleHandler.shared.notifyPause()
            case .background:
//                print("应用进入后台")
LifecycleHandler.shared.notifyStop()
            @unknown default:
//                print("未知状态")
break
            }
          }
     	}


		init(){
    	   // KoinKt.doInitKoin() //这里是ktor的依赖注解
    	    //这里调用kotlin
    	    let lifecycleObserver = AppLifecycleObserverImpl()
    	    appDelegate.lifecycleObserver = lifecycleObserver
    	}
}

class LifecycleHandler {
    static let shared = LifecycleHandler()

   var onStart: (() -> Void)?
       var onStop: (() -> Void)?
       var onResume: (() -> Void)?
       var onPause: (() -> Void)?

       private init() {}

       func notifyStart() {
           onStart?()
       }

       func notifyStop() {
           onStop?()
       }

       func notifyResume() {
           onResume?()
       }

       func notifyPause() {
           onPause?()
       }
}

func registerLifecycleObserver(observer: AppLifecycleObserver) {
    LifecycleHandler.shared.onStart = observer.onStart
    LifecycleHandler.shared.onStop = observer.onStop
    LifecycleHandler.shared.onResume = observer.onResume
    LifecycleHandler.shared.onPause = observer.onPause
}
//创建AppDelegate.swift 
import UIKit
import shared

//天坑,xcode编译可以选择文件是否编译,compile sources
class AppDelegate : NSObject, UIApplicationDelegate {

var window : UIWindow?

    var lifecycleObserver : AppLifecycleObserver?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        print("Application did finish launching")
        //初始化监听逻辑
           let lifecycleObserver = AppLifecycleObserverImpl()
                registerLifecycleObserver(observer: lifecycleObserver)
        return true
    }

    func applicationWillResignActive(_ application: UIApplication) {
        print("Application will resign active")
    }

    func applicationDidEnterBackground(_ application: UIApplication) {
        print("Application did enter background")
        lifecycleObserver?.onStop()
    }

    func applicationWillEnterForeground(_ application: UIApplication) {
        print("Application will enter foreground")
         lifecycleObserver?.onResume()
    }

    func applicationDidBecomeActive(_ application: UIApplication) {
        print("Application did become active")
    }

    func applicationWillTerminate(_ application: UIApplication) {
        print("Application will terminate")
    }
}

长图文本水印

这里要平台特性写码,略过下载这步,根据本地图片的路径,将该图片添加文本水印后生成在内部存储缓存目录,成功后复制一份到图册。在Android14 Color OS复制成功后删除缓存文件会报错,其他系统没问题,所以我就不立刻删除,在我的业务环境是打开APP时检测删。
坑,iOS端平时调用https的接口请求数据没有问题,但是https下载链接就报链接错误,TLS错误等,本质就是实现Darwin引擎的HttpClient。
水印长图效果
保存到图册的视频

iosMain模块

//这里是下载相关,不细讲
actual fun createHttpClient(): HttpClient {
    return HttpClient(Darwin) {
        install(HttpTimeout) {
            requestTimeoutMillis = 15_000
//            headers { }
        }
        install(ContentNegotiation) {
            json(Json {
                ignoreUnknownKeys = true
                prettyPrint = true
            })
        }
        install(Logging) {
//                level = LogLevel.ALL
            level = LogLevel.NONE //接口日志屏蔽
            logger = object : Logger {
                override fun log(message: String) {
                    println(message)
                }
            }
        }
    }
}

在iosMain模块创建 iOSGlobalEx.kt

fun getIOSRootDir(): String {
    val paths =
        NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true) as List<*>
    return paths.first() as String
}

//水印铺满
@OptIn(ExperimentalForeignApi::class)
fun addWaterMark(imagePath: String, markTxt: String): String? {
    val nsString = markTxt as NSString
    // Load image from file path
    val image: UIImage = UIImage.imageWithContentsOfFile(imagePath)
        ?: throw IllegalArgumentException("Invalid image path")
    val imageSize = image.size.useContents { CGSizeMake(width, height) }
    UIGraphicsBeginImageContext(imageSize)
    val context: CGContextRef =
        UIGraphicsGetCurrentContext() ?: throw RuntimeException("Failed to get graphics context")
    val imageWidth = imageSize.useContents { this.width }
    val imageHeight = imageSize.useContents { this.height }
    image.drawInRect(CGRectMake(0.0, 0.0, imageWidth, imageHeight))
    val attributes = mapOf(
        NSForegroundColorAttributeName to UIColor.grayColor,
        NSFontAttributeName to UIFont.systemFontOfSize(13.0)
    )
    val textSize = nsString.sizeWithAttributes(attributes as Map<Any?, *>)
    val textHeight = textSize.useContents { this.height }
    val textWidth = textSize.useContents { this.width }


    val stepX = textWidth + 80
    val stepY = textHeight + 80

    for (y in 0 until imageHeight.toInt() step stepY.toInt()) {
        for (x in 0 until imageWidth.toInt() step stepX.toInt()) {
            val drawRect = CGRectMake(x.toDouble(), y.toDouble(), textWidth, textHeight)

            CGContextSaveGState(context)
            CGContextTranslateCTM(
                context,
                drawRect.useContents { origin.x + size.width / 2 },
                drawRect.useContents { origin.y + size.height / 2 })
            CGContextRotateCTM(context, (-30.0 * PI / 180.0))
            CGContextTranslateCTM(
                context,
                -(drawRect.useContents { origin.x + size.width / 2 }),
                -(drawRect.useContents { origin.y + size.height / 2 })
            )
            nsString.drawInRect(drawRect, attributes)
            CGContextRestoreGState(context)
        }
    }

    val newImage = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()

    // Save new image to file
    var newPath: String? = null
    var format = "jpg"
    newImage?.let {
        if (imagePath.contains(".png")) {
            format = "png"
        }
        val nsPath = imagePath as NSString
        newPath = saveImage2Galley(newImage, format, nsPath.lastPathComponent)
    }
    return newPath
}

//保存图片到图册
@OptIn(ExperimentalForeignApi::class)
fun saveImage2Galley(image: UIImage, format: String = "jpg", fileName: String): String? {
    val imageData: NSData = when (format.lowercase()) {
        "png" -> image.PNGData()
        "jpg", "jpeg" -> image.JPEGData(1.0)
        else -> throw IllegalArgumentException("Unsupported format: $format")
    } ?: throw RuntimeException("Failed to get image representation")

    val name = if (fileName.endsWith(".$format")) fileName else "$fileName.$format"
    val filePath = "${getDevDCIM(getCacheLong(KmmConfig.USER_ID, 0))}/water$name"
    val nsUrl = NSURL.fileURLWithPath(filePath)
    imageData.writeToURL(nsUrl, true)

    UIImageWriteToSavedPhotosAlbum(image, null, null, null)
    printLogW("save?$filePath")
    return filePath
}

fun UIImage.PNGData(): NSData? = UIImagePNGRepresentation(this)
fun UIImage.JPEGData(compressionQuality: CGFloat): NSData? =
    UIImageJPEGRepresentation(this, compressionQuality)


//主动发起权限申请
fun requestPhotoPermission(callback: (Boolean) -> Unit) {
    PHPhotoLibrary.requestAuthorization { status ->
        callback(status == PHAuthorizationStatusAuthorized)
    }
}

调用系统图册查看视频或图片

 //在iOSGlobalEx.kt添加以下函数
//根据保存图册后得到的localIdentifier字符串打开图册或播放器,format是文件后缀
fun openImageById(localIdentifier: String, format: String) {
    val fetchOptions = PHFetchOptions()
    val assets = PHAsset.fetchAssetsWithLocalIdentifiers(listOf(localIdentifier), fetchOptions)
    val asset = assets.firstObject as? PHAsset ?: return

    val imageManager = PHImageManager.defaultManager()
    if (format == ".mp4" || format == ".mov" || format == ".m4v") {
        val options = PHVideoRequestOptions()
        options.version = PHVideoRequestOptionsVersionOriginal
        imageManager.requestAVAssetForVideo(asset, options) { avAsset, _, _ ->
            avAsset?.let {
                val playViewController = AVPlayerViewController()
                val item = AVPlayerItem(asset = avAsset)
                val player = AVPlayer(item)
                playViewController.player = player
                dispatch_async(dispatch_get_main_queue()) { //主线程执行,不然报错
                    val rootViewController =
                        UIApplication.sharedApplication.keyWindow?.rootViewController
                    rootViewController?.presentViewController(playViewController, animated = true) {
                        player.play()
                    }
                }
            }
        }
    } else {
        val options = PHImageRequestOptions()
        options.setNetworkAccessAllowed(true)
        options.setResizeMode(PHImageRequestOptionsResizeModeFast)
        options.setDeliveryMode(PHImageRequestOptionsDeliveryModeHighQualityFormat)
        imageManager.requestImageDataAndOrientationForAsset(asset, options) { data, _, _, _ ->
            data?.let {
                val tempDir = NSTemporaryDirectory()
                val filePath = "$tempDir/temp$format"
                val fileUrl = NSURL.fileURLWithPath(filePath)
                val suc = data.writeToURL(fileUrl, true)
                if (suc) {
                    openImage2Url(fileUrl)
                } else {
                    printLogW("failed file")
                }
            }
        }
    }
}

//打开内部私有目录的图片路径,图册里的图片被沙盒隔离,无法直接取,只能用localIdentifier,缓存目录可以路径
fun openImageBySystem(filePath: String) {
    printLogW("openImageBySystem>$filePath")
    val fileURL: NSURL = NSURL.fileURLWithPath(filePath)
    openImage2Url(fileURL)
}

fun openImage2Url(fileURL: NSURL) {
    val documentController = UIDocumentInteractionController()
    documentController.setURL(fileURL)
    val delegate = DocumentInteractionControllerDelegate()
    documentController.delegate = delegate

    val rootViewController = UIApplication.sharedApplication.keyWindow?.rootViewController
    rootViewController?.let {
        documentController.presentPreviewAnimated(true)
    }
}

fun openVideoBySystem(filePath: String) {
    printLogW("openVideoBySystem>$filePath")
    val fileURL = NSURL.fileURLWithPath(filePath)
    val player = AVPlayer(uRL = fileURL)
    val playViewController = AVPlayerViewController()
    playViewController.player = player
    dispatch_async(dispatch_get_main_queue()) {
        val rootViewController = UIApplication.sharedApplication.keyWindow?.rootViewController
        rootViewController?.presentViewController(playViewController, animated = true) {
            player.play()
        }
    }
}

class DocumentInteractionControllerDelegate : NSObject(),
    UIDocumentInteractionControllerDelegateProtocol {

    override fun documentInteractionControllerViewControllerForPreview(controller: UIDocumentInteractionController): UIViewController {
        return UIApplication.sharedApplication.keyWindow?.rootViewController!!
    }
}

androidMain模块 创建AddWaterMarkUtils.kt

object AddWaterMarkUtils {
//https://blog.csdn.net/u013762572/article/details/129991034
    fun setMarkPic(
        context: Context,
        picPath: String,
        waterTxt: String,
        needDCIM: Boolean = true //是否把结果图片放相册
    ): String {
        if (!File(picPath).exists()) {
            printLogW("源图片不存在 $picPath")
            return picPath
        }
        val sourceBmp = BitmapFactory.decodeFile(picPath)
        if (null == sourceBmp) {
            printLogW("源 Bitmap 不存在")
            return picPath
        }
        val wBmp =
            getMarkTxtBmp(MainApplication.appContext, waterTxt, sourceBmp.width, sourceBmp.height)

        val copiedBmp =
            Bitmap.createBitmap(sourceBmp.width, sourceBmp.height, Bitmap.Config.ARGB_8888)
        val targetCanvas = Canvas(copiedBmp)
        targetCanvas.drawFilter =
            PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
        targetCanvas.drawBitmap(sourceBmp, 0f, 0f, null)
        // 获取watermarkBitmap 和 sourceBitmap之间的比例,好进行缩放
        var arr = checkSample(sourceBmp, wBmp!!, readPictureDegree(picPath))
        val scaleW = arr[0] * 1.0f / wBmp.width
        val scaleH = arr[1] * 1.0f / wBmp.height
        val matrix = Matrix()
        matrix.postScale(scaleW, scaleH)
        //true : 以启用双线性过滤 为了就是不产生锯齿
        val scaleBmp = Bitmap.createBitmap(wBmp, 0, 0, wBmp.width, wBmp.height, matrix, true)
        val canvasPaint = Paint()
        canvasPaint.isAntiAlias = true
        canvasPaint.isFilterBitmap = true
        targetCanvas.drawBitmap(scaleBmp, 0f, (copiedBmp.height - arr[1]).toFloat(), canvasPaint)
        var saveSuc = false
        var newWaterMarkPath = ""
        if (needDCIM) {
            newWaterMarkPath =
                MainApplication.getDevDCIM(getCacheLong(KmmConfig.USER_ID)) +
                        File(picPath).name
        } else { //内部存储
            newWaterMarkPath =
                GlobalCode.getDownloadDir().toString() + File.separator + File(picPath).name
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {  //29
            newWaterMarkPath =
                saveBitmapWithAndroidQ(context, copiedBmp, File(picPath).name, needDCIM)
        } else {
            saveSuc = saveBitmapFileWithLow(context, copiedBmp, newWaterMarkPath) //bug
        }
        //注意原图未被删除
//        printD(
//            "水印图片保存状态=${Build.VERSION.SDK_INT} $saveSuc $picPath $newWaterMarkPath ${
//                File(
//                    newWaterMarkPath
//                ).exists()
//            }"
//        )

        try {
            val tempBmp = BitmapFactory.decodeFile(newWaterMarkPath) //这里得到的是xx.jpg  但是给的xx.png
            var tempBmpW = tempBmp.width
            var tempBmpH = tempBmp.height
            if (tempBmpH > 0 && tempBmpW > 0) {
                return newWaterMarkPath
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
        return picPath
    }

    fun getMarkTxtBmp(context: Context, txt: String, w: Int, h: Int): Bitmap? {
        val textSize: Float = 45f
        val inter: Float = 35f

        val sideLength: Int = if (w > h) {
//            Math.sqrt((2 * (w * w)).toDouble()).toInt()
            w
        } else {
//            Math.sqrt((2 * (h * h)).toDouble()).toInt()
            h
        }

        val paint = Paint(Paint.ANTI_ALIAS_FLAG)
        val rect = Rect()
        paint.textSize = textSize
        paint.getTextBounds(txt, 0, txt.length, rect)
        val strWidth = rect.width()
        val strHeight = rect.height()
        var markBmp: Bitmap? = null
        try {
            markBmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
            val canvas = Canvas(markBmp)
            //创建透明画布
            canvas.drawColor(Color.TRANSPARENT)
            paint.color = Color.BLACK
            paint.alpha = (0.1 * 255f).toInt()
            paint.isDither = true  // 获取更清晰的图像采样
            paint.isFilterBitmap = true
            //先平移,再旋转才不会有空白,使整个图片充满
            if (w > h) {
                canvas.translate(w.toFloat() - sideLength.toFloat() - inter, sideLength - w + inter)
            } else {
                canvas.translate(h.toFloat() - sideLength.toFloat() - inter, sideLength - h + inter)
            }
            canvas.rotate(-45f)   //将该文字图片逆时针方向倾斜45度
//            var i = 0
//            while (i <= sideLength) {
//                var count = 0
//                var j = 0
//                while (j <= sideLength) {
//                    if (count % 2 == 0) {
//                        printD("x-y1=$i $j")
//                        canvas.drawText(txt, i.toFloat(), j.toFloat(), paint)
//                    } else {
//                        //偶数行进行错开
//                        canvas.drawText(txt, (i + strWidth / 2).toFloat(), j.toFloat(), paint)
//                        printD("x-y22=$i $j")
//                    }
//                    j = (j.toFloat() + inter + strHeight.toFloat()).toInt()
//                    count++
//                }
//                i = (i.toFloat() + strWidth.toFloat() + inter).toInt()
//            }
            var index = 0
            var positionY = 0
            while (positionY <= sideLength) {
                val fromX = (-sideLength + index++ % 2 * strWidth).toFloat()
                var positionX = fromX
                while (positionX < w) {
                    canvas.drawText(txt, positionX, positionY.toFloat(), paint)
                    positionX += (strWidth * 2).toFloat()
                }
                positionY += 200 //当  用180挺正常 sideLength/30
            }
            canvas.save()
        } catch (e: Exception) {
            if (markBmp != null && !markBmp.isRecycled) {
                markBmp.recycle()
                markBmp = null
            }
            e.printStackTrace()
        }
        return markBmp
    }


    fun saveBitmapFileWithLow(context: Context, bitmap: Bitmap?, filePath: String?): Boolean {
        if (bitmap == null) { //
            return false
        }
        var fos: FileOutputStream? = null
        var file: File? = null
        return try {
            file = File(filePath)
            if (!file.exists()) {
                file.parentFile.mkdirs()
                file.createNewFile()
            }
            fos = FileOutputStream(file)
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
            fos.flush()
            true
        } catch (e: IOException) {
            e.printStackTrace()
            false
        } finally {
            try {
                //            MediaStore.Images.Media.insertImage(context.getContentResolver(),file.getAbsolutePath(),fileName,null);
                MediaStore.Images.Media.insertImage(
                    context.contentResolver,
                    bitmap,
                    file.toString(),
                    null
                )
                val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
                val uri = Uri.fromFile(file)
                intent.data = uri
                context.sendBroadcast(intent)
                //                ToastUtils.showToast(context, "图片保存成功");
            } catch (e: java.lang.Exception) {
                e.printStackTrace()
                //                ToastUtils.showToast(context, "图片保存失败");
            }
            closeAll(fos)
        }
    }

    fun saveBitmapWithAndroidQ(
        context: Context,
        bitmap: Bitmap,
        displayName: String,
        needDCIM: Boolean = true
    ): String {
        val contentValues = ContentValues()
        val arr = displayName.split(".")
        if (arr.size > 1) {
            contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, arr[0])
        } else {
            contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
        }

        if (needDCIM) {
            contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, getMediaStorePath())
            if (displayName.contains(".png")) {
                contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/png")
            } else {
                contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
            }
            val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
            val contentResolver = context.contentResolver
            val insertUri: Uri? =
                insert(contentResolver, uri, contentValues)
            if (insertUri != null) {
                try {
                    contentResolver.openOutputStream(insertUri).use { outputStream ->
                        if (outputStream != null) {
                            if (displayName.contains(".png")) {
                                bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
                            } else {
                                bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
                            }
                            return getRealPathFromUri(context, insertUri) + ""
                        }
                    }
                } catch (e: IOException) {
                    e.printStackTrace()
                }
            }
        } else { //预览水印图片不需要放图册
//            contentValues.put(
//                MediaStore.MediaColumns.RELATIVE_PATH,
//                "${Environment.DIRECTORY_DOWNLOADS}/ark_download/" //只能pic 或dcim
//            )
            val tempPath = GlobalCode.getDownloadDir().toString() + File.separator + displayName
            var fos: FileOutputStream? = null
            try {
                fos = FileOutputStream(File(tempPath))
                bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
                fos.flush()
                return tempPath
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }

        return ""
    }

    private fun closeAll(vararg closeables: Closeable?) {
        if (closeables == null) {
            return
        }
        for (closeable in closeables) {
            if (closeable != null) {
                try {
                    closeable.close()
                } catch (e: IOException) {
                    e.printStackTrace()
                }
            }
        }
    }

    private fun insert(resolver: ContentResolver, uri: Uri, values: ContentValues): Uri? {
        var insertUri = resolver.insert(uri, values)
        if (insertUri == null) {
            val originFileName = values.getAsString(MediaStore.Images.Media.DISPLAY_NAME)
            values.put(
                MediaStore.Images.Media.DISPLAY_NAME,
                appendFileNameTimeSuffix(originFileName)
            )
            insertUri = resolver.insert(uri, values)
        }
        return insertUri
    }

    /**
     * 给文件名后面添加 (时间戳) 后缀
     */
    private fun appendFileNameTimeSuffix(originFileName: String): String? {
        val appendName = "(" + System.currentTimeMillis() + ")"
        return appendFileSuffix(originFileName, appendName)
    }

    /**
     * 给文件名加后缀
     *
     *
     * 添加规则:
     * 1. 如文件名不包含任何 . ,将后缀添加到文件后;
     * 2. 如文件名包含. , 但在最前面,将后缀添加到文件头部,不影响文件真实后缀;
     */
    private fun appendFileSuffix(originFileName: String, suffix: String): String? {
        val resultFileName: String
        val i = originFileName.lastIndexOf(".")
        resultFileName = if (i == -1) {
            originFileName + suffix
        } else if (i == 0) {
            suffix + originFileName
        } else {
            originFileName.substring(0, i) + suffix + originFileName.substring(i)
        }
        Log.v("TAG", "append=$resultFileName")
        return resultFileName
    }

    private fun checkSample(
        sourceBitmap: Bitmap,
        waterMarkBitmap: Bitmap,
        getOrientation: Int
    ): IntArray {
        var sourceBitmapWidth = sourceBitmap.width
//        val sourceBitmapHeight = sourceBitmap.height
        var waterMarkWidth = waterMarkBitmap.width
        var waterMarkHeight = waterMarkBitmap.height
        if (getOrientation == 90 || getOrientation == 270) { //横向
            sourceBitmapWidth = sourceBitmap.height
            if (waterMarkWidth > sourceBitmapWidth * 0.5f) {
                waterMarkWidth = (sourceBitmapWidth * 0.65f).toInt()
                waterMarkHeight = waterMarkBitmap.height * waterMarkWidth / waterMarkBitmap.width
            }
        } else {
            if (waterMarkWidth > sourceBitmapWidth * 0.8f) {
                waterMarkWidth = (sourceBitmapWidth * 0.8f).toInt()
                waterMarkHeight = waterMarkBitmap.height * waterMarkWidth / waterMarkBitmap.width
            }
        }
        return intArrayOf((waterMarkWidth * 1.3f).toInt(), (waterMarkHeight * 1.3f).toInt())
    }

    fun readPictureDegree(path: String): Int {
        var degree = 0
        try {
            val exifInterface = ExifInterface(path)
            val orientation: Int = exifInterface.getAttributeInt(
                ExifInterface.TAG_ORIENTATION,
                ExifInterface.ORIENTATION_NORMAL
            )
            when (orientation) {
                ExifInterface.ORIENTATION_ROTATE_90 -> degree = 90
                ExifInterface.ORIENTATION_ROTATE_180 -> degree = 180
                ExifInterface.ORIENTATION_ROTATE_270 -> degree = 270
            }
        } catch (e: IOException) {
            e.printStackTrace()
        }
        return degree
    }
}

shared模块

expect fun createWaterPic(filePath: String)

各自平台实现

//android
actual fun createWaterPic(filePath: String) {
 val markPath = AddWaterMarkUtils.setMarkPic(
        MainApplication.appContext,
        filePath, "boy name"
    )
    }
//ios
actual fun createWaterPic(filePath: String) {
val markName="gril name"
 val newPath = addWaterMark(filePath, markName )
 }

如果只想将下载的缓存图片保存到图册,成功后返回路径

//shared下
expect fun saveMedia2Gallery(imagePath: String, callback:(String)->Unit):String?

//androidMain
//有需要再贴吧,这里太长了,要处理视频和图片的类型,uri的转换等,太长了

//iosMain saveMedia2Gallery() 函数修改20240712
//这个函数的目的是将缓存目录路径文件保存到图册,成功后返回路径;经过测试发现
//这里系统保存是异步进行,会发生函数直接返回值了但是保存动作未完成,所以这里添加callback
//还有问题是图册返回的路径在iOS应用是被沙盒隔离,非图册应用拿到文件路径也无法识别,
//但是可以用localIdentifier,后面就是根据它作参数拉起系统的图册或视频播放,不能用路径了
actual fun saveMedia2Gallery(imagePath: String, callback: (String?) -> Unit): String? {
    var outPath: String? = null
    var localIdentifier: String? = null
    if (GlobalCode.isVideoFileByPath(imagePath)) { //判断是否是视频
        val videoUrl = NSURL.fileURLWithPath(imagePath)
        PHPhotoLibrary.sharedPhotoLibrary().performChanges({
            val creationRequest =
                PHAssetChangeRequest.creationRequestForAssetFromVideoAtFileURL(videoUrl)
            val assetPlaceHolder = creationRequest?.placeholderForCreatedAsset
            localIdentifier = assetPlaceHolder?.localIdentifier
        }, completionHandler = { success, error ->
            if (success) {
                getMediaFilePath(localIdentifier, 1) {
//                    callback(it) //返回的路径被沙盒隔离,用不了
                    callback(localIdentifier)
                }
                printLogW("Video saved successfully to gallery.")
            } else {
                printLogW("Error saving video to gallery: ${error?.localizedDescription}")
            }
        })
    } else if (imagePath.contains(".gif")) {
        val gifUrl = NSURL.fileURLWithPath(imagePath)
        PHPhotoLibrary.sharedPhotoLibrary().performChanges({
            val creationRequest =
                PHAssetChangeRequest.creationRequestForAssetFromImageAtFileURL(gifUrl)
            val assetPlaceHolder = creationRequest?.placeholderForCreatedAsset
            localIdentifier = assetPlaceHolder?.localIdentifier
        }) { success, error ->
            if (success) {
                getMediaFilePath(localIdentifier) {
//                    callback(it)
                    callback(localIdentifier)
                }
                printLogW("Gif saved successfully to gallery.")
            } else {
                printLogW("Error saving Gif to gallery: ${error?.localizedDescription}")
            }
        }
    } else {
        //获取图片
        val uiImage = UIImage.imageWithContentsOfFile(imagePath)
        // 调用原生 iOS 函数保存图片到系统图册
        PHPhotoLibrary.sharedPhotoLibrary().performChanges({
            val creationRequest = PHAssetChangeRequest.creationRequestForAssetFromImage(uiImage!!)
            val assetPlaceHolder = creationRequest.placeholderForCreatedAsset
            localIdentifier = assetPlaceHolder?.localIdentifier
        }) { success, error ->
            if (success) {
                getMediaFilePath(localIdentifier) {
//                    callback(it) //返回路径
                    callback(localIdentifier) 
                }
                printLogW("Image saved successfully! $localIdentifier ")
            } else {
                printLogW("Error saving image: ${error?.localizedDescription}")
            }
        }
    }
    return outPath //如何获取新的路径
}

private fun getMediaFilePath(localIdentifier: String?, type: Int = 0, outback: (String?) -> Unit) {
    var outPath: String? = null
    localIdentifier?.let { //通过这个获取路径
        val fetchResult =
            PHAsset.fetchAssetsWithLocalIdentifiers(listOf(localIdentifier), null)
        val asset = fetchResult.firstObject as? PHAsset
        asset?.requestContentEditingInputWithOptions(null) { input, _ ->
            if (type == 1) { //视频
                //这里又一种类型转换获取值的方法
                outPath = (input?.audiovisualAsset?.valueForKey("URL") as? NSURL)?.absoluteString
            } else {
                outPath = input?.fullSizeImageURL?.absoluteString
            } //handle>file:///var/mobile/Media/DCIM/100APPLE/IMG_0029.MP4 //图册是个应用,数据被沙盒隔离用不了
            printLogW("handle>$outPath")//handle>file:///var/mobile/Media/DCIM/100APPLE/IMG_0026.GIF
            outback(outPath)
        }
    }
}

文档预览

从腾讯文件浏览服务把arr下载放到libs,debug编译没有问题,但是用build apk打release包就不通过了,参照上篇的文章build.gradle文件

预览xls效果

//用第三方sdk预览
 androidMain.dependencies {
            //引入本地Android aar库
            implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
            implementation(files("../androidApp/libs/TbsFileSdk_dwg_universal_release_1.0.5.6000030.20231109143411.aar"))
            }

iosMain模块

说明几个坑,viewDidAppear()要在窗口出现后再启动预览视图,不然报错,类似Android绘制控件后才有宽高;关闭预览时会自动再打开,所以我优化了下处理previewControllerDidDismiss(),还有最无脑的语法问题,目前kmp太新了很多官方包装了的类都没人使用过,previewController()这里的返回值QLPreviewItemProtocol,但是网上的原生所有接口返回值都是 QLPreviewItem,这里做语法的再包装。iOS很多类被kotlin包装后都改了,网上直接抄是不行的,这确实难都好几次放弃😂最后硬着头皮搞出来。

//创建IOSFilePreviewer.kt
package com.your.pk

import kotlinx.cinterop.ExportObjCClass
import platform.Foundation.NSURL
import platform.Foundation.lastPathComponent
import platform.QuickLook.QLPreviewController
import platform.QuickLook.QLPreviewControllerDataSourceProtocol
import platform.QuickLook.QLPreviewControllerDelegateProtocol
import platform.QuickLook.QLPreviewItemProtocol
import platform.UIKit.UIApplication
import platform.UIKit.UIViewController
import platform.darwin.NSInteger
import platform.darwin.NSObject

/**
 * @author by jason-何伟杰,2024/7/2
 * des:iOS系统支持预览文档,但是要依赖viewController
 */
//ios的类型不同,kotlin重新包装了
@ExportObjCClass
class PreviewItem(private val url: NSURL) : NSObject(), QLPreviewItemProtocol {

    override fun previewItemURL(): NSURL {
        return url
    }

    override fun previewItemTitle(): String? {
        return url.lastPathComponent
    }
}

@ExportObjCClass
class DocumentPreviewController : UIViewController, QLPreviewControllerDelegateProtocol,
    QLPreviewControllerDataSourceProtocol {

    var documentURL: NSURL? = null
    lateinit var previewController: QLPreviewController
    private var isPreviewing: Boolean = false

    constructor(documentURL: NSURL) : super(nibName = null, bundle = null) {
        this.documentURL = documentURL
    }

    //要用这个,代表视图打开后再加载
    override fun viewDidAppear(animated: Boolean) {
        super.viewDidAppear(animated)
        if (!isPreviewing) {
            isPreviewing = true
            previewController = QLPreviewController()
            previewController.delegate = this
            previewController.dataSource = this
            previewController.dismissViewControllerAnimated(animated, completion = {
                presentViewController(previewController, animated = true, completion = null)
            })
        }
    }

    override fun numberOfPreviewItemsInPreviewController(controller: QLPreviewController): NSInteger {
        return 1
    }

    override fun previewControllerDidDismiss(controller: QLPreviewController) {
        cancel(animated = true)
    }

    private fun cancel(animated: Boolean) {
        isPreviewing = false
        previewController.dismissViewControllerAnimated(animated, completion = null)
        //关闭预览窗口后,还要关闭UIViewControl
        dismissViewControllerAnimated(animated, completion = null)
    }

    override fun previewController(
        controller: QLPreviewController,
        previewItemAtIndex: Long
    ): QLPreviewItemProtocol {
//        return documentURL as QLPreviewItemProtocol
        return PreviewItem(documentURL!!) //这样转类型
    }
}

// Extension to call UIViewController from Kotlin
fun openDocumentPreview(documentPath: String) {
    val documentURL = NSURL.fileURLWithPath(documentPath)
    val previewController = DocumentPreviewController(documentURL)
    val rootViewController = UIApplication.sharedApplication.keyWindow!!.rootViewController!!
    rootViewController.presentViewController(previewController, animated = true, completion = null)
}

在Platform.ios.kt 20240712新增

//在我的业务每个文件下载我都保存它的路径,这里是以截取下载链接最后一节作为key,保存路径作为value,图册的是localIdentifier
//expect fun saveStr(key: String, value: String)
 val path1 = saveMedia2Gallery(desPath, callback = { s ->
                                                printLogW("s>$s")
                                                s?.let {
                                                    saveStr(GlobalCode.subFormatUrl(task.url), s)
                                                }
                                          //仅供参考,那么就可以快速拿参数拉起查看图片
                                            })

//使用simple,//filePath可以是链接,也可保存的路径,不是标识localIdentifier哦
val a1="https://ark-cdn.gree.com/ark/%E4%BD%8E%E7%B3%96%E9%A5%AD%E7%85%B2-%E6%94%B9%281%29_1653470699039.mp4?sign=f004d5090f6079866f0f770a6fd335c6&t=1720746000"
val a2 =getDevDCIM(getCacheLong(KmmConfig.USER_ID)) + "/%E4%BD%8E%E7%B3%96%E9%A5%AD%E7%85%B2-%E6%94%B9%281%29_1653470699039(1).mp4"
//AppLauncher(a1) //根据已下载的链接文件
//AppLauncher(a2) //根据下载到缓存目录的路径

fun AppLauncher(filePath: String) {
    if (filePath.contains(".pdf") //大小写?
        || filePath.contains(".pptx")
        || filePath.contains(".doc")
        || filePath.contains(".docx")
        || filePath.contains(".xls")
        || filePath.contains(".xlsx")
        || filePath.contains(".txt")
    ) {
        openDocumentPreview(filePath)
    } else { //打开图片、视频
        var format = ".png"
        if (filePath.contains(".jpg")) {
            format = ".jpg"
        } else if (filePath.contains(".jpeg")) {
            format = ".jpeg"
        } else if (filePath.contains(".gif")) {
            format = ".gif"
        } else if (filePath.contains(".mp4")) {
            format = ".mp4"
        } else if (filePath.contains(".m4v")) {
            format = ".m4v"
        } else if (filePath.contains(".mov")) {
            format = ".mov"
        }
        //文件存在私有路径可用,图片因为被复制到图册,但是图册的路径被隔离
        //通过研究测试,图册的路径被沙盒隔离无法对外使用,所以改存localIdentifier
        //有时路径,有时链接,乱套了
        if (GlobalCode.fileExist(filePath)) {  //缓存目录资源 //路径判断应该避免随机变的问题
            if (GlobalCode.isVideo(format)) {
                openVideoBySystem(filePath)
            } else {
                openImageBySystem(filePath)
            }
        } else { //判断如果是链接
            getCacheStr(GlobalCode.subFormatUrl(filePath))?.let {
                if (!it.contains(format)) { //是标识获取,代表资源从缓存目录复制到图册
                    openImageById(it, format)
                } else {
                    if (GlobalCode.isVideo(format)) {
                        openVideoBySystem(filePath)
                    } else {
                        openImageBySystem(filePath)
                    }
                }
            }
        }
    }
}

iosApp模块下

//跟iOSApp.swift同级创建 DocumentPreviewController.swift
import UIKit
import QuickLook

class DocumentPreviewController: UIViewController, QLPreviewControllerDelegate, QLPreviewControllerDataSource {

    var documentURL: URL?

    override func viewDidLoad() {
        super.viewDidLoad()
        let previewController = QLPreviewController()
        previewController.delegate = self
        previewController.dataSource = self
        present(previewController, animated: true, completion: nil)
    }

    func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
        return 1
    }

    func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
        return documentURL! as QLPreviewItem
    }
}

队列下载

为了降低难度,实现的是单线程的队列下载,线程用协程去处理耗时,但是中间也遇到各种问题,1.协程的任务归为job,队列时就是一个下载结束就启动新的协程job,那么上一个的job永远会包着下一个 scope.launch{ val job=next() },那就不能用job.isCompleted去判断是否进行下一个;
2.断点下载的核心是header里的 append(HttpHeaders.Range, “bytes=$existLength-”),contentType(ContentType.Any);
3.下载进度问题,由于进制换算问题,我们要的百分之几的再网速波动大的时候很容易重复,又或者是计算1024时永远无法100%,这是因为文件大小保留位数的问题,不能单纯的靠计算100%来判断下载结束,val channel: ByteReadChannel = httpResponse.body() if (channel.isClosedForRead) 这样判断;
4.断点下载进行中,突然没网了,返回的httpResponse.code还是200;
5.下载中的文件后缀加 .tmp,下载结束改名去掉,发现如果有同名文件在移动文件耗时比较大,就是每次下载同一个文件,又不对文件名进行唯一性处理,这种移动文件的很耗时。

//
//还有个网速计算有bug,太长了代码后续公布

总结

目前是以功能为导向开发来验证kotlin multiplatform +compose multiplatform框架的技术可行性,目前来看还是要会两端更多的知识后还有会compose的技术、语法,相当苦逼呀。目前困难应该是查阅的资料不多,网上都是自己简单入门,要么就是搞个log,目前还是外国的技术引导的迭代比较快。

Jason Ho
2024/7/8

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值