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文件
//用第三方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