一个Android本地阅读器的核心功能实现,kotlin+jetpack+mvvm版本

四年前曾经写过一篇主题相同的文章,简单描述了其核心实现,但彼时因为能力问题,代码并不清晰,现在自觉有些进步就把项目重新写了一遍,希望可以描述地更清楚。

有兴趣的朋友可以移步项目地址 Reader

先看效果

阅读器R-简介

其实和原来那版变化不大,只是改变了设计模式和代码风格。

思路

在开始之前,我先思考了阅读器应该是什么样子。

  • 逐行排版文章中的内容
  • 保存当前阅读位置
  • 翻页以向前/后阅读

逐行排版文章中的内容

排版时每遇到文章中的\n,即line feed换行符就结束一行,之后的文字需新起一行排版
若未到\n,屏幕尺寸已不足,排版应新起一行继续,直到遇到文章中的\n
一个换行符到下一个换行符之间的内容,我们把它称为段落
暂且得出结论,行的内容是由文章的段落与屏幕的尺寸一同决定的,我们需要找到段落。

段落以\n终止,我们可以通过它来寻找段落。
两个选择

  1. 在茫茫Byte当中寻找\n的位置,随后将指定长度的ByteArray读为字符串
  2. 将ByteArray整体读取为字符串后寻找\n

前者在逻辑上更加繁琐,需要按byte遍历二进制文件,后者逻辑上虽然更简单,但必须将ByteArray整体读取后才能检索,这将影响性能,所以这里我决定使用byte检索

保存当前阅读位置

因为采用了byte检索,所以阅读位置也应该是byte中的position

翻页以向前/后阅读

在byte检索中,翻页行为其实是向前或向后移动position

实践

class Printer(private var book: Book){
    private val bookFile = File(book.path)
    private val raf: RandomAccessFile
    private val bookBytes: MappedByteBuffer
    private val byteChannel: FileChannel
    private var pageBegin = book.readProgressInByte
    private var currentPosition = 0
    private var currentPage = Page(emptyList(),0)
    private var mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    init {
    	//将二进制文件映射到内存以提高索引速度,需要注意的是我这里直接将整个文件映射到了内存当中,这并不优雅
        val file = File(book.path)
        raf = RandomAccessFile(file,"r")
        byteChannel = raf.channel
        bookBytes = byteChannel.map(FileChannel.MapMode.READ_ONLY,0,file.length())
    }

    fun closeBook(){
        byteChannel.close()
        raf.close()
    }


    fun getCurrentBookStateForSave(): Book{
        val readProgressInByte = pageBegin
        val builder = StringBuilder()
        currentPage.lines.forEach{builder.append(it)}
        val lastReadParagraph = builder.toString()
        val lastReadTime = System.currentTimeMillis()
        return book.copy(readProgressInByte = readProgressInByte,lastReadParagraph = lastReadParagraph,lastReadTime = lastReadTime)
    }

    fun getBook(): Book{
        return book;
    }

    fun setEncoding(encode: String){
        this.book = book.copy(encode = encode)
    }

    fun skipToProgress(progress: Float){
        val p = min(100f,max(0f,progress))/100
        val targetPos = (bookFile.length() * p).toInt()
        val availablePos = findLastParagraphStartPos(targetPos)
        pageBegin = availablePos
    }
    fun skipToPosition(position: Int){
        pageBegin = position
    }

    /**
     * 从当前page begin位置开始排版一页内容,并将current position 移动到下一页首个byte
     */
    fun print(config: Config): Page{
        val page = compose(pageBegin,config)
        currentPage = page
        currentPosition = page.position
        //这里使用当前页首的位置,而非下一页首的位置
        return page.copy(position = pageBegin)
    }

    /**
     * 从下一页首个byte位置开始排版一页内容,并将current position移动到排版后的下一页首个byte
     */
    fun pageDown(config: Config): Page{
        if(currentPosition >= bookBytes.limit()){
            return currentPage
        }
        val page = compose(currentPosition,config)
        currentPage = page
        pageBegin = currentPosition
        currentPosition = page.position
        return page.copy(position = pageBegin)
    }

    /**
     *从当前page begin位置上翻一页排版一页内容,并将page begin移动到当前页首个byte,current position为下一页首个byte
     */
    fun pageUp(config: Config): Page {
        if(pageBegin == 0){
            return currentPage.copy(position = 0)
        }
        val start = findLastPageBegin(pageBegin,config)
        val page = compose(start,config)
        currentPage = page
        pageBegin = start
        currentPosition = page.position
        return page.copy(position = pageBegin)
    }

    /**
     * 将一段byte读为string
     * @param start 起始位置,inclusive
     * @param end 终止位置,exclusive
     */
    private fun readParagraphString(start: Int,end: Int): String{
        val length = end - start
        if(length <= 0){
            return ""
        }
        val bytes = ByteArray(length)
        for (i in bytes.indices){
            bytes[i] = bookBytes.get(start+i)
        }
        return String(bytes,Charset.forName(book.encode))
    }
    /**
     *寻找下一段首的byte位置,若已到文件末尾则返回-1
     * @param currentStart inclusive
     */
    private fun findNextParagraphStartPos(currentStart: Int): Int{
        val lf = 10.toByte()
        val cr = 13.toByte()
        if(currentStart >= bookBytes.limit()){
            //reach the end
            return -1
        }
        for(i in currentStart until bookBytes.limit()){
            if(bookBytes[i] == lf){
                //i为段尾/n,i+1指向下一段首byte
                return i+1
            }
        }
        return bookBytes.limit()
    }

    /**
     * 在bytes文件当中根据换行符按序查询上一段落的起始byte position
     * @return 上一段的起始byte position
     */
    private fun findLastParagraphStartPos(currentStart: Int): Int{
        if(currentStart == 0){
            return 0
        }
        val start = currentStart - 1
        val lf = 10.toByte() // \n
        val cr = 13.toByte() // \r
        for(i in start downTo 0){
            if(i != start && bookBytes[i] == lf){
                    return i+1
            }
        }
        return 0
    }

    private fun measureLineCount(config: Config): Int{
        val availableHeight = config.height - config.marginTop - config.marginBottom - config.bottomBarHeight
        val lineHeight = config.textSize + config.lineSpace
        return (availableHeight/lineHeight).toInt()
    }

    /**
     * 正序测量该行尾Index,returned index is exclusive
     * @return 第一行 end position
     */
    private fun measureLineIndexForward(text: String,config: Config): Int{
        val availableWidth = config.width - config.marginStart - config.marginEnd
        mPaint.textSize = config.textSize
        for( i in 1 until text.length){
            //测量行长度时,忽略cr与lf,避免当\r\n出现在行尾时发生拆分,导致排版多出一行的问题
            if(text[i-1] == '\r' || text[i-1] == '\n'){
                continue
            }
            if(mPaint.measureText(text.substring(0,i)) > availableWidth){
                return i - 1
            }
        }
        return text.length
    }

    /**
     * 倒序测量最后一行起始index
     *
     * ## 注意这里的代码逻辑
     *
     * 虽然是倒序测量,但并非literally从后向前测量,而是从前向后测量每行直到获得最后一行起始位置
     *
     * 这里还忽略了lf与cr的measure长度,是为了避免错误的拆分
     *
     * 这是为了保持前后翻页排版的一致性。
     * @return 最后一行start index,若单行可以填充则返回0
     */
    private fun measureLineIndexBackward(text: String,config: Config): Int{
        if(text.isEmpty()){
            return 0
        }
        mPaint.textSize = config.textSize
        val availableWidth = config.width - config.marginStart - config.marginEnd
        var lineStart = 0
        var temp: String
        for(i in 1 .. text.length){
            //测量行长度时,忽略cr与lf,避免当\r\n出现在行尾时发生拆分,导致排版多出一行的问题
            if(text[i-1] == '\r' || text[i-1] == '\n'){
                continue
            }
            temp = text.substring(lineStart,i)
            if(mPaint.measureText(temp) > availableWidth){
                lineStart = i - 1
            }
        }
        return lineStart
    }

    /**
     * 从后向前计算上一页begin position,printer的核心方法之一
     * @return 上一页起始position
     */
    private fun findLastPageBegin(currentPageBegin: Int,config: Config): Int{
        val lineCount = measureLineCount(config)
        //段落string
        var paragraph = ""
        //每行使用段落的index
        var textIndex = 0
        //byte position
        var mPosition = currentPageBegin
        //循环需要的行数填充
        for (i in 0 until lineCount){
            //若textIndex为0则说明本段paragraph已经打印完毕,再向前读取一个paragraph
            if(textIndex == 0){
                val lastParagraphStartPos = findLastParagraphStartPos(mPosition)
                //已经到文件首部,直接返回0
                if(lastParagraphStartPos == 0){
                    return 0
                }
                //将bytes读为string,使用book中的encode
                paragraph = readParagraphString(lastParagraphStartPos,mPosition)
                //移动mPosition到当前位置
                mPosition = lastParagraphStartPos
            }
            //测量当前屏幕参数中一行所需要的字数,因为是上翻页,所以这里的textIndex是最后一行的起始index
            textIndex = measureLineIndexBackward(paragraph,config)
            //paragraph"打印"消耗了这段文字,剩余的paragraph进入下一循环进行打印
            paragraph = paragraph.substring(0, textIndex)
        }
        //当所有行都填充完毕后,若textIndex不为0则说明paragraph未全部使用,需要在byte position中补正
        if(textIndex != 0){
            //方式很简单,string encoding为byte,依旧使用book中的encode
            mPosition += paragraph.substring(0,textIndex).toByteArray(Charset.forName(book.encode)).size
        }
        //返回计算出的Position
        return mPosition
    }


    /**
     * 从前向后阅读一页,printer的核心方法之一
     * @return Page,包含已读取的页面内容以及读取本页后的byte position
     */
    private fun compose(start: Int,config: Config): Page{
        //获取页面行数
        val lineCount = measureLineCount(config)
        val lines = mutableListOf<String>()
        //段落string
        var paragraph = ""
        //byte position
        var mPosition = start
        //循环填充
        for(i in 0 until lineCount){
            //若paragraph为空,则向后读取一个byte paragraph并读为string
            if(paragraph.isEmpty()){
                val newStart = findNextParagraphStartPos(mPosition)
                //若newStart为-1,则说明当前mPosition已到达文件limit,直接返回已有内容
                if(newStart == -1){
                    return Page(lines,mPosition)
                }
                paragraph = readParagraphString(mPosition,newStart)
                //byte position后移
                mPosition = newStart
            }
            //测量本行需要使用paragraph中多少字
            val lineEnd = measureLineIndexForward(paragraph,config)
            lines.add(paragraph.substring(0,lineEnd))
            //更新paragraph,因为“使用”了一行
            paragraph = paragraph.substring(lineEnd,paragraph.length)
        }
        //若行排版完毕paragraph仍有剩余,则需要byte position补正
        if(paragraph.isNotEmpty()){
            val offsetCorrection = paragraph.toByteArray(Charset.forName(book.encode)).size
            mPosition -= offsetCorrection
        }

        return Page(lines,mPosition)
    }


    data class Page(
        val lines: List<String>,
        val position: Int,
    )
    data class Config(
        val bottomBarHeight: Float,
        val marginTop: Float,
        val marginBottom: Float,
        val marginStart: Float,
        val marginEnd: Float,
        val lineSpace: Float,
        val textSize: Float,
        val height: Float,
        val width: Float
    ){
        companion object{
            fun build(p: PrintConfig,width: Float,height: Float): Config{
                return Config(p.bottomBarHeight,p.textMarginTop,p.textMarginBottom,p.textMarginStart,p.textMarginEnd,p.textLineSpace,p.textSize,height,width)
            }
        }
    }
}

Printer是Reader的核心,实现了上/下翻页以及config发生改变时的文字排版

  • 5
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
实现上下布局和中间表单介于中心部分,可以使用 ConstraintLayout,这是 Android Jetpack一个强大的布局管理器。以下是 Kotlin 代码示例: ``` <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <!-- 上方布局 --> <LinearLayout android:id="@+id/topLayout" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" app:layout_constraintTop_toTopOf="parent"> <!-- 上方布局中的控件 --> </LinearLayout> <!-- 下方布局 --> <LinearLayout android:id="@+id/bottomLayout" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" app:layout_constraintBottom_toBottomOf="parent"> <!-- 下方布局中的控件 --> </LinearLayout> <!-- 中间表单 --> <LinearLayout android:id="@+id/middleLayout" android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" app:layout_constraintTop_toBottomOf="@id/topLayout" app:layout_constraintBottom_toTopOf="@id/bottomLayout" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"> <!-- 中间表单中的控件 --> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout> ``` 在这个示例中,我们使用了三个 LinearLayout,其中上方布局和下方布局使用了 ConstraintLayout 的属性,将它们分别与父布局的顶部和底部对齐。中间表单使用了 ConstraintLayout 的属性,将它固定在上方布局和下方布局之间,并填充了剩余的空间。可以根据实际情况调整布局的控件和属性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值