compose multiplatform写一个简单的阅读器
目录
kmp相关的的几个名词说明
app开发与功能
打包与签名
计划实现的功能
kmp相关的的几个名词说明
jetpack compose:这是jetbrains与谷歌开发的一个,主要用于android的ui库.
jetbrain kotlin multiplatform,这是jb公司开发的一个多平台库,有native,js等后端.
compose multiplatform:这是jb与谷歌一起做的,将compose运用于多平台的库.
as需要安装后面两个插件,kotlin multiplatform, compose multiplatform.
建立项目的时候才会出现相应的.但官方似乎主要针对移动端有示例.上篇文章有写过.
app开发与功能
借用别人的开源项目:
GitHub - zt64/compose-pdf: Kotlin multiplatform library for compose multiplatform to assist in viewing PDF files from various sources
虽然是借用,但我在桌面端作了修改,替换了解析库为Mupdf.完善一些功能.
actual与expect,这两个是新的关键字.主要用于多平台,后者是声明这是一个多平台需要实现的.前者则是每一个平台具体实现.
但官方又建议,不要过度使用它,有时接口更好.
原作者是使用icepdf-core来作为pdf的解析,这个毛病就是慢,好处是java实现.直接使用.
替换为mupdf:
- 打包的时候,发布到本地的maven.发一个jar.
- dylib/jnilib放到commonMain/resource/下
- 替换相应的解码代码
https://github.com/archko/amupdf-nativelib 这个项目里面有打包mupdf aar的代码, 打包为本地的就可以了.
然后可以手动修改打包代码,将生成的aar修改为jar.
或者直接进入./m2/com/xx/mupdf/的aar目录下面,新建一个 1.25.1目录,其它文件与aar一样,就是把aar里面的class.jar拿出来 ,名字修改一下为对应的jar,然后外部的两个配置文件修改为jar,添加版本号就可以了.
在主项目的gradle末尾添加:
tasks.withType<JavaExec> {doFirst {val libDir = "${projectDir}/src/commonMain/resources/macos-aarch64"val existingPath = System.getProperty("java.library.path")val newPath = if (existingPath.isNullOrEmpty()) {libDir} else {"$existingPath:$libDir"}systemProperty("java.library.path", newPath)}
}
将dylib放入macos-aarch64中,这样项目就会引用它了.
修改代码:
package dev.zt64.compose.pdfimport androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageBitmapConfig
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
import com.archko.reader.pdf.Item
import com.artifex.mupdf.fitz.ColorSpace
import com.artifex.mupdf.fitz.Cookie
import com.artifex.mupdf.fitz.Document
import com.artifex.mupdf.fitz.DrawDevice
import com.artifex.mupdf.fitz.Matrix
import com.artifex.mupdf.fitz.Pixmap
import com.artifex.mupdf.fitz.Rect
import dev.zt64.compose.pdf.component.Size
import java.awt.image.BufferedImage
import java.io.File
import java.io.InputStream
import java.net.URLinternal const val SCALE = 1.0f@Stable
public actual class LocalPdfState(private val document: Document) : PdfState {public actual override val pageCount: Int = document.countPages()override var pageSizes: List<Size> = listOf()get() = fieldset(value) {field = value}override var outlineItems: List<Item>? = listOf()get() = fieldset(value) {field = value}private fun prepareSizes(): List<Size> {val list = mutableListOf<Size>()for (i in 0 until pageCount) {val page = document.loadPage(i)val bounds = page.boundsval size = Size(bounds.x1.toInt() - bounds.x0.toInt(),bounds.y1.toInt() - bounds.y0.toInt(),i)page.destroy()list.add(size)}return list}private fun prepareOutlines(): List<Item> {return document.loadOutlineItems()}/*public constructor(inputStream: InputStream) : this(document = Document.openDocument(inputStream).apply {setInputStream(inputStream, null)}) {pageSizes = prepareSizes()}*/public actual constructor(file: File) : this(document = Document.openDocument(file.absolutePath)) {pageSizes = prepareSizes()outlineItems = prepareOutlines()}/*public constructor(url: URL) : this(document = Document().apply {setUrl(url)}) {pageSizes = prepareSizes()}*/public actual override fun renderPage(index: Int, viewWidth: Int, viewHeight: Int): Painter {val page = document.loadPage(index)val bounds = page.boundsval scale: Floatif (viewWidth > 0) {scale = (1f * viewWidth / (bounds.x1 - bounds.x0))} else {return BitmapPainter(ImageBitmap(viewWidth, viewHeight, ImageBitmapConfig.Rgb565))}println("renderPage:index:$index, scale:$scale, $viewWidth-$viewHeight, bounds:${page.bounds}")val ctm: Matrix = Matrix.Scale(scale)/* Render page to an RGB pixmap without transparency. */val bmp: ImageBitmap?try {val bbox: Rect = Rect(bounds).transform(ctm)val pixmap = Pixmap(ColorSpace.DeviceBGR, bbox, true)pixmap.clear(255)val dev = DrawDevice(pixmap)page.run(dev, ctm, Cookie())dev.close()dev.destroy()val pixmapWidth = pixmap.widthval pixmapHeight = pixmap.heightval image = BufferedImage(pixmapWidth, pixmapHeight, BufferedImage.TYPE_3BYTE_BGR)image.setRGB(0, 0, pixmapWidth, pixmapHeight, pixmap.pixels, 0, pixmapWidth)bmp = image.toComposeImageBitmap()return BitmapPainter(bmp)} catch (e: Exception) {System.err.println(("Error loading page " + (index + 1)) + ": " + e)}return BitmapPainter(ImageBitmap(viewWidth, viewHeight, ImageBitmapConfig.Rgb565))}override fun close() {document.destroy()}
}/*** Remembers a [LocalPdfState] for the given [inputStream].** @param inputStream* @return [LocalPdfState]*/
@Composable
public fun rememberLocalPdfState(inputStream: InputStream): LocalPdfState {return remember { LocalPdfState(File("")) }
}/*** Remembers a [LocalPdfState] for the given [url].** @param url* @return [LocalPdfState]*/
@Composable
public actual fun rememberLocalPdfState(url: URL): LocalPdfState {return remember { LocalPdfState(File("")) }
}/*** Remembers a [LocalPdfState] for the given [file].** @param file* @return [LocalPdfState]*/
@Composable
public actual fun rememberLocalPdfState(file: File): LocalPdfState {return remember { LocalPdfState(file) }
}
这里我去了url加载的代码,减少麻烦.
这样解码的部分就修改完成了.
这功能太简单了,没有大纲.没有历史记录.所以需要添加这两个功能
历史记录需要存储,用数据库.sqldelight,图片显示用coil.
首页去了url,添加一个grid,显示阅读过的历史记录.
点击每一个历史记录,需要加载保存的页码,而不是从0开始.
这里只写关于桌面端的:
main.kt里面添加coil的初始化
setSingletonImageLoaderFactory { context ->ImageLoader.Builder(context).components { add(CustomImageFetcher.Factory()) }.logger(DebugLogger(minLevel = Logger.Level.Warn)).build()}
这个作用就是自定义加载器,去解析历史记录中对应的每一个文件,取出首页.当然也可以修改为解码第一页后存储为图片.
自定义一个图片获取器:这个放到lib/jvmMain项目中.
public class CustomImageFetcher(private val data: CustomImageData,private val options: Options
) : Fetcher {override suspend fun fetch(): FetchResult? {return try {val doc = Document.openDocument(data.path)val image = renderPage(doc, 0, data.width, data.height)val outputStream = ByteArrayOutputStream()val awtImage = image.toAwtImage()ImageIO.write(awtImage, "png", outputStream)val byteArray = outputStream.toByteArray()val skiaImage = org.jetbrains.skia.Image.makeFromEncoded(byteArray)val coilImage = Bitmap.makeFromImage(skiaImage).asImage()ImageFetchResult(image = coilImage,isSampled = false,dataSource = DataSource.MEMORY)} catch (e: IOException) {null}}public fun renderPage(document: Document,index: Int,viewWidth: Int,viewHeight: Int): ImageBitmap {val page = document.loadPage(index)val bounds = page.boundsval scale: Floatif (viewWidth > 0) {scale = (1f * viewWidth / (bounds.x1 - bounds.x0))} else {return ImageBitmap(viewWidth, viewHeight, ImageBitmapConfig.Rgb565)}println("renderPage:index:$index, scale:$scale, $viewWidth-$viewHeight, bounds:${page.bounds}")val ctm: Matrix = Matrix.Scale(scale)/* Render page to an RGB pixmap without transparency. */val bmp: ImageBitmap?try {val bbox: Rect = Rect(bounds).transform(ctm)val pixmap = Pixmap(ColorSpace.DeviceBGR, bbox, true)pixmap.clear(255)val dev = DrawDevice(pixmap)page.run(dev, ctm, Cookie())dev.close()dev.destroy()val pixmapWidth = pixmap.widthval pixmapHeight = pixmap.heightval image = BufferedImage(pixmapWidth, pixmapHeight, BufferedImage.TYPE_3BYTE_BGR)image.setRGB(0, 0, pixmapWidth, pixmapHeight, pixmap.pixels, 0, pixmapWidth)bmp = image.toComposeImageBitmap()return bmp} catch (e: Exception) {System.err.println(("Error loading page " + (index + 1)) + ": " + e)}return ImageBitmap(viewWidth, viewHeight, ImageBitmapConfig.Rgb565)}public class Factory : Fetcher.Factory<CustomImageData> {override fun create(data: CustomImageData,options: Options,imageLoader: ImageLoader): Fetcher {return CustomImageFetcher(data, options)}}
}
public data class CustomImageData(val path: String, val width: Int, val height: Int) 这个类放到lib工程的commonMain中.
sqldelight官方有详细的文档,就不写过程了.
创建vm,进入主页面:
val windowInfo = LocalWindowInfo.currentval density = LocalDensity.currentvar screenWidthInPixels by remember { mutableStateOf(0) }var screenHeightInPixels by remember { mutableStateOf(0) }density.run {screenWidthInPixels = windowInfo.containerSize.width.toDp().toPx().toInt()screenHeightInPixels = windowInfo.containerSize.height.toDp().toPx().toInt()}println("app.screenHeight:$screenWidthInPixels-$screenHeightInPixels")val driverFactory: DatabaseDriverFactory = DriverFactory()val database = AppDatabase(driverFactory.createDriver())val viewModelStoreOwner = remember { ComposeViewModelStoreOwner() }CompositionLocalProvider(LocalViewModelStoreOwner provides viewModelStoreOwner) {val viewModel: PdfViewModel = viewModel()viewModel.database = databaseApplication(screenWidthInPixels, screenHeightInPixels, viewModel)}
vm主要是处理历史记录,对于pdf的加载,使用原项目的state.
public class PdfViewModel : ViewModel() {public var database: AppDatabase? = nullprivate val _recentList = MutableStateFlow<List<Recent>>(mutableListOf())public val recentList: StateFlow<List<Recent>> = _recentListpublic var progress: Progress? = nullpublic fun insertOrUpdate(path: String, pageCount: Long) {viewModelScope.launch {println("insertOrUpdate:${progress}")}}public fun updateProgress(page: Long, pageCount: Long, zoom: Double, crop: Long) {println("updateProgress:$page, pc:$pageCount, old:$progress")progress?.run {viewModelScope.launch {progress = database?.appDatabaseQueries?.selectProgress(path)?.executeAsOneOrNull()loadRecents()}}}public fun loadRecents() {viewModelScope.launch {val progresses = database?.appDatabaseQueries?.selectProgresses()?.executeAsList()if (progresses != null) {if (progresses.isNotEmpty()) {val list = mutableListOf<Recent>()_recentList.value = list}}}}public fun clear() {viewModelScope.launch {database?.appDatabaseQueries?.removeAllProgresses()loadRecents()}}
}
pdf=null里面url那部分代码去了,换成grid
LazyVerticalGrid(columns = GridCells.FixedSize(140.dp),verticalArrangement = Arrangement.spacedBy(16.dp),horizontalArrangement = Arrangement.spacedBy(16.dp)) {items(count = recentList.size,key = { index -> "$index" }) { i ->recentItem(recentList[i]) {val file = KmpFile(File(it.path))pdf = LocalPdfState(file)loadProgress(viewModel, file, pdf)}}}
private fun recentItem(recent: Recent, click: (Recent) -> Unit) {Column(modifier = Modifier.fillMaxSize().padding(1.dp).clickable { click(recent) }) {Box(modifier = Modifier.fillMaxWidth().height(180.dp).shadow(elevation = 8.dp,shape = RoundedCornerShape(8.dp),clip = false,ambientColor = Color.Black.copy(alpha = 0.2f),spotColor = Color.Black.copy(alpha = 0.4f)).border(BorderStroke(1.dp, Color.LightGray))) {AsyncImage(model = recent.path?.let {CustomImageData(it,180.dp.toIntPx(),135.dp.toIntPx())},contentDescription = null,contentScale = ContentScale.Crop,modifier = Modifier.fillMaxSize())Text(modifier = Modifier.align(Alignment.BottomEnd).padding(2.dp),color = Color.Blue,maxLines = 1,text = "${recent.page?.plus(1)}/${recent.pageCount}",fontSize = 11.sp,overflow = TextOverflow.Ellipsis)}Text(modifier = Modifier.padding(2.dp),color = Color.Black, maxLines = 2,text = "${recent.path?.inferName()}",fontSize = 13.sp,overflow = TextOverflow.Ellipsis)}
}
这样历史记录就出来了.
优化列表的显示:
private fun PdfScreen(screenWidth: Int,screenHeight: Int,pdf: PdfState,onClickBack: () -> Unit,scope: CoroutineScope,viewModel: PdfViewModel,lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
) {val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()val snackbarHostState = remember { SnackbarHostState() }var scale by rememberSaveable { mutableFloatStateOf(1f) }val lazyListState = rememberLazyListState()// 创建一个 FocusRequester 用于请求焦点val focusRequester1 = FocusRequester()val focusRequester2 = FocusRequester()var width by remember { mutableIntStateOf(screenWidth) }var height by remember { mutableIntStateOf(screenHeight) }val tocVisibile = remember { mutableStateOf(false) }val currentPage by remember {derivedStateOf { lazyListState.firstVisibleItemIndex + 1 }}val scrollbarState = lazyListState.scrollbarState(itemsAvailable = pdf.pageCount,)// 在组合完成后请求焦点,这个用于键盘控制LaunchedEffect(Unit) {focusRequester1.requestFocus()println("launch.progress:${viewModel.progress}")viewModel.progress?.page?.let { lazyListState.scrollToItem(it.toInt()) }}DisposableEffect(pdf) {val observer = LifecycleEventObserver { _, event ->//println("event:$event") ,暂停的时候,其实是不可见,保存历史记录if (event == Lifecycle.Event.ON_PAUSE) {viewModel.updateProgress(lazyListState.firstVisibleItemIndex.toLong(),pdf.pageCount.toLong(),1.0,1)}}lifecycleOwner.lifecycle.addObserver(observer)onDispose {//println("onDispose")/页面销毁时保存历史记录viewModel.updateProgress(lazyListState.firstVisibleItemIndex.toLong(),pdf.pageCount.toLong(),1.0,1)lifecycleOwner.lifecycle.removeObserver(observer)}}Scaffold(//modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),modifier = Modifier.background(Color.White),topBar = {TopAppBar(title = {Text(color = Color.White,text = "$currentPage/${pdf.pageCount}")},navigationIcon = {IconButton(onClick = onClickBack) {Icon(Icons.Default.Close, contentDescription = null)}},actions = {IconButton(onClick = {scope.launch { tocVisibile.value = !tocVisibile.value }}) {Icon(Icons.AutoMirrored.Filled.Toc, contentDescription = null)}IconButton(onClick = { scale -= 0.1f }) {Icon(Icons.Default.ZoomOut, contentDescription = null)}IconButton(onClick = { scale += 0.1f }) {Icon(Icons.Default.ZoomIn, contentDescription = null)}},scrollBehavior = scrollBehavior)},//snackbarHost = { SnackbarHost(snackbarHostState) }
//将原来的滚动topbar去了.固定的) { paddingValues ->@Composablefun screen() {Box(modifier = Modifier.fillMaxSize().background(Color.Transparent).padding(paddingValues)) {PdfColumn(modifier = Modifier.fillMaxSize()//.padding(end = 8.dp) // 为滚动条留出空间.onSizeChanged {width = it.widthheight = it.heightprintln("app.LazyColumn:$width-$height, $screenWidth-$screenHeight")}.pointerInput(Unit) {detectTapGestures(onTap = {scope.launch {focusRequester1.requestFocus()}})}.focusRequester(focusRequester1).focusable(enabled = true).onKeyEvent { event ->// 处理按键按下事件if (event.type == KeyEventType.KeyDown) {//println("${event.type}, ${event.key}")when (event.key) {Key.Enter,Key.Spacebar -> {scope.launch {lazyListState.scrollBy(height.toFloat() - 10)}return@onKeyEvent true}Key.PageUp -> {scope.launch {lazyListState.scrollBy(-height.toFloat() + 10)}return@onKeyEvent true}Key.PageDown -> {scope.launch {lazyListState.scrollBy(height.toFloat() - 10)}return@onKeyEvent true}Key.DirectionUp -> {scope.launch {lazyListState.scrollBy(-120f)}return@onKeyEvent true}Key.DirectionDown -> {scope.launch {lazyListState.scrollBy(120f)}return@onKeyEvent true}else -> return@onKeyEvent false}} else {return@onKeyEvent false // 返回 false 表示事件未处理}},//.scale(scale),viewWidth = width,viewHeight = height,state = pdf,lazyListState = lazyListState)//这是一个滚动条,从谷歌项目中拿的,右侧可以有一个滑块用于拖动lazyListState.DraggableScrollbar(modifier = Modifier.fillMaxHeight().padding(horizontal = 2.dp).align(Alignment.CenterEnd),state = scrollbarState,orientation = Orientation.Vertical,onThumbMoved = lazyListState.rememberDraggableScroller(itemsAvailable = pdf.pageCount,),)}}//显示大纲.目前无法聚焦是个问题.@Composablefun toc() {if (pdf.outlineItems == null || pdf.outlineItems!!.isEmpty()) {Text(modifier = Modifier.width(240.dp).fillMaxHeight(),fontSize = 24.sp,text = "No Outline")} else {LazyColumn(modifier = Modifier.width(240.dp).background(Color.White).fillMaxHeight().hoverable(enabled = true, interactionSource = MutableInteractionSource()).focusRequester(focusRequester2).focusable(enabled = true),state = lazyListState,) {items(count = pdf.outlineItems!!.size,key = { index -> "$index" }) { i ->Row {Text(modifier = Modifier.weight(1f),fontSize = 14.sp,color = Color.Black,maxLines = 1,overflow = TextOverflow.Ellipsis,text = pdf.outlineItems!![i].title.toString())Text(modifier = Modifier,fontSize = 12.sp,color = Color.Black,text = pdf.outlineItems!![i].page.toString())VerticalDivider(thickness = 0.5.dp,modifier = Modifier.fillMaxHeight())}}}}}if (tocVisibile.value) {Row(modifier = Modifier.fillMaxSize().padding(paddingValues).background(Color.Transparent)) {toc()screen()}} else {screen()}}
}
代码没有很复杂,主要是PdfColumn,显示一个列表,但现在列表没有缩放功能,只是可以加载页面.
添加了屏幕的高宽,参数的原因是,当页面未加载的时候,如果没有高宽,那么就无法定位到目标页,滚动来回时会有问题.
在onSizeChanged中,获取了view的高宽.而加载的pdfstate中,取得的是一个pagesize列表,这样每一页就有高宽了,去计算当前与view的关系.
每一个页面
@Composable
public fun PdfPage(state: PdfState,index: Int,width: Int,height: Int,modifier: Modifier = Modifier,loadingIconTint: Color = Color.White,errorIconTint: Color = Color.Red,iconSize: Dp = 40.dp,loadingIndicator: @Composable () -> Unit = {val h: Intif (state.pageSizes.isNotEmpty()) {val size = state.pageSizes[index]val xscale = 1f * width / size.widthh = (size.height * xscale).toInt()println("PdfPage: image>height:$h, view.w-h:$width-$height, page:${size.width}-${size.height}")} else {h = height}val hdp = with(LocalDensity.current) {h.toDp()}Box(modifier = modifier.fillMaxWidth().height(hdp).background(loadingIconTint)) {LoadingView("Page $index")/*Text(modifier = Modifier.align(Alignment.Center),color = Color.Black,fontSize = 30.sp,text = "Page $index")*/}},errorIndicator: @Composable () -> Unit = {Image(painter = state.renderPage(index, width, height),contentDescription = null,colorFilter = if (errorIconTint ==Color.Unspecified) {null} else {ColorFilter.tint(errorIconTint)},modifier = Modifier.size(iconSize))},contentScale: ContentScale = ContentScale.Fit
) {//val loadState = if (state is RemotePdfState) state.loadState else LoadState.Successval imageState: MutableState<Painter?> = remember { mutableStateOf(null) }DisposableEffect(index) {val scope = CoroutineScope(SupervisorJob())scope.launch {snapshotFlow {if (isActive) {return@snapshotFlow state.renderPage(index, width, height)} else {return@snapshotFlow null}}.flowOn(Dispatcher.DECODE).collectLatest {imageState.value = it}}onDispose {scope.cancel()}}if (imageState.value != null) {Image(painter = imageState.value!!,contentDescription = null,contentScale = ContentScale.FillWidth)} else {loadingIndicator()}/*when (loadState) {LoadState.Success -> Image(modifier = modifier.background(Color.White),painter = imageState.value!!,//state.renderPage(index, width, height),contentDescription = null,contentScale = contentScale)LoadState.Loading -> loadingIndicator()LoadState.Error -> loadingIndicator()}*/
}@Composable
private fun LoadingView(text: String = "Decoding",modifier: Modifier = Modifier
) {Column(verticalArrangement = Arrangement.Center,modifier = modifier.fillMaxWidth().fillMaxHeight()) {Text(text,style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.Black),modifier = Modifier.align(Alignment.CenterHorizontally))Spacer(modifier = Modifier.height(24.dp))LinearProgressIndicator(modifier = Modifier.height(10.dp).align(alignment = Alignment.CenterHorizontally))}
}
主要是loadingIndicator作了修改,这样可以加载中也与加载完成一样高宽.
使用了DisposableEffect加载页面,单线程,不然会出错.
大致修改就是这些了.
项目的功能不多,如果这个主体用到手机上,是比较难受的.手势功能目前不支持.后续考虑去修改完善它.
打包与签名
打包目前遇到arm上可以打包,intel的cpu上打包不成功,估计官方也不想修改了,毕竟对于mac来说,都是新的cpu了.
签名目前没有写上.
dmg的包大概快300mb了,解码速度略输于它,现在是整个页面解码,这么快的电脑,还不如手机快.
cpu占用略高于pdf expert,应该是java的线程切换带来的.
内存占用,初始化的时候比较大要500m,虚拟机占用大,加载页面后的占用大小与pdf expert是差不多的.
体积自然是因为runtime这个东西大.还有skia都要打包进去.
pdf expert是mac是最优秀的阅读器之一.性能不错.就是要钱.
为什么要去写这个阅读器,因为我有一个完整的手机版的阅读器,自己写的,如果这个顺利,会全部用compose,多端共享,同步阅读.
另一个想法是,epub,目前没有找到一个合适的阅读器,要么就是要复制整个文件过去,要么是其它的原因,而mupdf可以解析它.这样我可以在电脑上直接查看epub,而不用修改为pdf了.
https://download.csdn.net/download/archko/90397912 整个项目资源下载,需要时间审核.免费下载.
计划实现的功能
如果时间允许,会把手机已经实现的功能搬过来.
- 分块解码,解码重构.
- compose替换原来的view实现,多平台共用
- 添加链接高亮,点击.搜索.
- 修正大纲的焦点问题.
- 页面放大缩小的实现.
- 坚果等webdav的历史同步.
- epub/mobi的布局高宽调整
- window/linux打包测试.
- 源码发布到github