kotlin开源项目——免费在线小说阅读器

这是一款基于kotlin的免费Android小说应用。
本项目参考了https://github.com/390057892/reader 项目,
将原项目的阅读功能单独打包成了readerlib库来使用,摒除了原项目繁杂的依赖关系,可以方便的应用于其他项目。

在这里对原作者表示衷心的感谢!

项目明细

环境:

android studio 3.5
kotlin_version 1.3.61
kotlin_coroutines 1.3.3

特点:

本项目采用Serverless模式进行开发,数据源来自于jsoup抓取网页数据,功能大部分使用Android官方数据库框架Room在本地实现,使用了kotlin协程和Rxjava来处理异步数据,开箱即用。具有很好的学习和参考价值。

主要功能

1.在线搜索,分类浏览,查看详情
2.加入本地书架阅读
3.阅读偏好设置
4.记录搜索历史
5.记录阅读进度
6.添加本地书籍到书架
7.管理书架
(更多新增功能、UI优化、bug修复还在陆续填坑中...)

预览

device-2020-02-20-141340.png

device-2020-02-20-141601.png

device-2020-02-20-141616.png

项目架构

本项目采用采用MVC架构进行开发,所有核心功能都由BookRegistory抽象类实现,开发者只需要重写相关抽象方法即可实现对应的功能,将核心模块完全解耦出来,方便功能迁移和二次开发。

关键代码

BookRegistory模块

/**
 * Created by newbiechen on 17-5-8.
 * 存储关于书籍内容的信息(CollBook(收藏书籍),BookChapter(书籍列表),ChapterInfo(书籍章节),BookRecord(记录),BookSignTable书签)
 */
abstract class BookRepository {
    /**
     * 保存阅读记录
     */
    abstract fun saveBookRecord(mBookRecord: ReadRecordBean)

    /**
     * 获取阅读记录
     */
    abstract fun getBookRecord(bookUrl: String, readRecordListener: OnReadRecordListener)

    /**
     * 获取章节列表
     */
    abstract fun chapterBeans(mCollBook: BookBean, onChaptersListener: OnChaptersListener, start: Int = 0)

    /**
     * 获取章节内容
     */
    abstract fun requestChapterContents(mCollBook: BookBean, requestChapters: List<ChapterBean?>, onChaptersListener: OnChaptersListener)

    abstract fun saveBookChaptersWithAsync(bookChapterBeanList: List<ChapterBean>, mCollBook: BookBean)
    /**
     * 存储章节
     *
     * @param folderName
     * @param fileName
     * @param content
     */
    fun saveChapterInfo(folderName: String, fileName: String, content: String) {
        val filePath = (Constant.BOOK_CACHE_PATH + folderName
                + File.separator + fileName + FileUtils.SUFFIX_NB)
        if (File(filePath).exists()) {
            return
        }
        val str = content.replace("\\\\n\\\\n".toRegex(), "\n")
        val file = BookManager.getBookFile(folderName, fileName)
        //获取流并存储
        var writer: Writer? = null
        try {
            writer = BufferedWriter(FileWriter(file))
            writer.write(str)
            writer.flush()
        } catch (e: IOException) {
            e.printStackTrace()
            close(writer)
        }
    }

    /**
     * 加入书架
     */
    abstract fun saveCollBookWithAsync(mCollBook: BookBean)

    /**
     * 书签是否已存在
     */
    abstract fun hasSigned(chapterUrl: String): Boolean

    /**
     * 添加书签
     */
    abstract fun addSign(mBookUrl: String, chapterUrl: String, chapterName: String, bookSignsListener: OnBookSignsListener)

    /**
     * 删除书签
     */
    abstract fun deleteSign(vararg bookSign: BookSignTable)

    /**
     * 获取书签列表
     */
    abstract fun getSigns(bookUrl: String, bookSignsListener: OnBookSignsListener)


    /**
     * 加载插图
     */
    abstract fun loadBitmap(context: Context, imageUrl: String, bitmapLoadListener: OnBitmapLoadListener)

    /**
     * 音量键翻页开关
     */
    abstract fun canTurnPageByVolume(): Boolean
}

BookRegistory实现类

import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.text.TextUtils
import android.util.Log
import cn.mewlxy.novel.appDB
import cn.mewlxy.novel.jsoup.DomSoup
import cn.mewlxy.novel.jsoup.OnJSoupListener
import cn.mewlxy.novel.utils.showToast
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.SimpleTarget
import com.bumptech.glide.request.transition.Transition
import com.mewlxy.readlib.interfaces.OnBitmapLoadListener
import com.mewlxy.readlib.interfaces.OnBookSignsListener
import com.mewlxy.readlib.interfaces.OnChaptersListener
import com.mewlxy.readlib.interfaces.OnReadRecordListener
import com.mewlxy.readlib.model.*
import com.mewlxy.readlib.utlis.MD5Utils
import com.mewlxy.readlib.utlis.SpUtil
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jsoup.nodes.Document
import org.reactivestreams.Subscriber
import org.reactivestreams.Subscription

/**
 * description:
 * author:luoxingyuan
 */
open class BookRepositoryImpl : BookRepository() {
    private val uiScope = CoroutineScope(Dispatchers.Main)
    private val domSoup = DomSoup()
    var lastSub: Subscription? = null

    companion object {
        val instance: BookRepositoryImpl by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
            BookRepositoryImpl()
        }
    }


    private fun getChapterContent(chapterBean: ChapterBean): Single<ChapterBean> {

        return Single.create {
            uiScope.launch(Dispatchers.IO) {
                val chapterContent = appDB.chapterDao().getChapterContent(chapterBean.url)
                launch(Dispatchers.Main) {
                    if (chapterContent.isNullOrBlank()) {
                        domSoup.getSoup(chapterBean.url, object : OnJSoupListener {
                            override fun start() {
                            }

                            override fun success(document: Document) {
                                val paragraphTags = document.body().getElementById("content")
                                        .getElementsByTag("p")
                                val stringBuilder = StringBuilder()
                                for (p in paragraphTags) {
                                    stringBuilder.append("\t\t\t\t").append(p.text()).append("\n\n")
                                }
                                chapterBean.content = stringBuilder.toString()
                                it.onSuccess(chapterBean)

                                launch(Dispatchers.IO) {
                                    val chapterModel = ChapterModel()
                                    chapterModel.id = chapterBean.id
                                    chapterModel.name = chapterBean.name
                                    chapterModel.url = chapterBean.url
                                    chapterModel.content = chapterBean.content
                                    chapterModel.bookName = chapterBean.bookName
                                    chapterModel.bookUrl = chapterBean.bookUrl
                                    appDB.chapterDao().updates(chapterModel)
                                }
                            }

                            override fun failed(errMsg: String) {
                                it.onError(Throwable(errMsg))
                            }
                        })
                    } else {
                        chapterBean.content = chapterContent
                        it.onSuccess(chapterBean)
                    }
                }
            }

        }
    }

    override fun saveBookRecord(mBookRecord: ReadRecordBean) {
        try {
            uiScope.launch(Dispatchers.IO) {
                mBookRecord.bookMd5 = MD5Utils.strToMd5By16(mBookRecord.bookUrl)!!
                try {
                    appDB.readRecordDao().inserts(ReadRecordModel.createReadRecordModel(mBookRecord))
                } catch (e: Exception) {
                }
            }
        } catch (e: Exception) {
            Log.e("error", e.toString())
        }
    }

    override fun getBookRecord(bookUrl: String, readRecordListener: OnReadRecordListener) {
        readRecordListener.onStart()
        var readRecordModel: ReadRecordModel?
        try {
            uiScope.launch(Dispatchers.IO) {
                readRecordModel = appDB.readRecordDao().getReadRecord(MD5Utils.strToMd5By16(bookUrl)!!)
                launch(Dispatchers.Main) {
                    readRecordListener.onSuccess(if (readRecordModel == null) ReadRecordModel() else readRecordModel!!)
                }
            }
        } catch (e: Exception) {
            readRecordListener.onError(e.toString())
        }
    }

    override fun chapterBeans(mCollBook: BookBean, onChaptersListener: OnChaptersListener, start: Int) {
        onChaptersListener.onStart()
        try {
            uiScope.launch(Dispatchers.IO) {
                val chapters = arrayListOf<ChapterBean>()
                chapters.addAll(appDB.chapterDao().getChaptersByBookUrl(mCollBook.url, start = start).map {
                    return@map it.convert2ChapterBean()
                })
                launch(Dispatchers.Main) {
                    onChaptersListener.onSuccess(chapters)
                }
            }
        } catch (e: Exception) {
            onChaptersListener.onError(e.toString())
        }
    }

    override fun requestChapterContents(mCollBook: BookBean, requestChapters: List<ChapterBean?>, onChaptersListener: OnChaptersListener) {
        lastSub?.cancel()
        onChaptersListener.onStart()
        val singleList = requestChapters.map {
            return@map getChapterContent(it!!)
        }

        val newChapters = arrayListOf<ChapterBean>()
        Single.concat(singleList)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(object : Subscriber<ChapterBean> {
                    override fun onComplete() {
                        onChaptersListener.onSuccess(newChapters)
                    }

                    override fun onSubscribe(s: Subscription?) {
                        s?.request(Int.MAX_VALUE.toLong())
                        lastSub = s

                    }

                    override fun onNext(chapterBean: ChapterBean) {
                        newChapters.add(chapterBean)
                        //存储章节内容到本地文件
                        if (chapterBean.content.isNotBlank()) {
                            saveChapterInfo(MD5Utils.strToMd5By16(chapterBean.bookUrl)!!, chapterBean.name, chapterBean.content)
                        }
                    }

                    override fun onError(t: Throwable?) {
                        onChaptersListener.onError(t.toString())
                    }

                })
    }


    override fun saveBookChaptersWithAsync(bookChapterBeanList: List<ChapterBean>, mCollBook: BookBean) {
        uiScope.launch(Dispatchers.IO) {
            try {
                appDB.chapterDao().inserts(*(bookChapterBeanList.map {
                    return@map ChapterModel.convert2ChapterModel(it)
                }.toTypedArray()))
            } catch (e: Exception) {
            }
        }

    }

    override fun saveCollBookWithAsync(mCollBook: BookBean) {

        val bookModel = BookModel.convert2BookModel(mCollBook)
        if (!TextUtils.isEmpty(bookModel.url)) {
            uiScope.launch(Dispatchers.IO) {
                val url = appDB.bookDao().queryFavoriteByUrl(bookModel.url)?.url
                val favorite = appDB.bookDao().queryFavoriteByUrl(bookModel.url)?.favorite
                withContext(Dispatchers.Main) {
                    if (TextUtils.isEmpty(url) && favorite == null) {
                        launch(Dispatchers.IO) {
                            bookModel.favorite = 1
                            try {
                                appDB.bookDao().inserts(bookModel)
                            } catch (e: Exception) {
                            }
                        }
                        showToast("加入书架成功")
                    } else if (!TextUtils.isEmpty(url) && favorite == 0) {
                        launch(Dispatchers.IO) {
                            bookModel.favorite = 1
                            appDB.bookDao().update(bookModel)
                        }
                        showToast("加入书架成功")
                    } else {
                        showToast("该书籍已在书架中")
                    }
                }
            }
        }
    }

    //---------------------------------------------书签相关---------------------------------------------
    override fun hasSigned(chapterUrl: String): Boolean {
        var bookSign: BookSignModel? = null
        uiScope.launch(Dispatchers.IO) {
            bookSign = appDB.bookSignDao().getSignsByChapterUrl(chapterUrl)
        }
        return bookSign != null
    }

    override fun addSign(mBookUrl: String, chapterUrl: String, chapterName: String, bookSignsListener: OnBookSignsListener) {
        bookSignsListener.onStart()
        val bookSign = BookSignModel()
        bookSign.bookUrl = mBookUrl
        bookSign.chapterUrl = chapterUrl
        bookSign.chapterName = chapterName
        try {
            uiScope.launch(Dispatchers.IO) {
                if (appDB.bookSignDao().getSignsByChapterUrl(chapterUrl) == null) {
                    try {
                        appDB.bookSignDao().inserts(bookSign)
                    } catch (e: Exception) {
                    }
                    launch(Dispatchers.Main) {
                        bookSignsListener.onSuccess(mutableListOf(bookSign))
                    }
                } else {
                    launch(Dispatchers.Main) {
                        showToast("本章节书签已经存在")
                    }
                }
            }
        } catch (e: Exception) {
            bookSignsListener.onError(e.toString())
        }
    }

    override fun deleteSign(vararg bookSign: BookSignTable) {
        uiScope.launch(Dispatchers.IO) {
            val list = bookSign.map {
                return@map it as BookSignModel
            }.toTypedArray()
            appDB.bookSignDao().delete(*list)
        }
    }

    override fun getSigns(bookUrl: String, bookSignsListener: OnBookSignsListener) {
        bookSignsListener.onStart()
        val bookSigns = mutableListOf<BookSignModel>()
        try {
            uiScope.launch(Dispatchers.IO) {
                bookSigns.addAll(appDB.bookSignDao().getSignsByBookUrl(bookUrl))
                launch(Dispatchers.Main) {
                    bookSignsListener.onSuccess(bookSigns)
                }
            }
        } catch (e: Exception) {
            bookSignsListener.onError(e.toString())
        }

    }

    override fun loadBitmap(context: Context, imageUrl: String, bitmapLoadListener: OnBitmapLoadListener) {
        try {
            Glide.with(context).asBitmap().load(imageUrl).thumbnail(0.1f).into(object : SimpleTarget<Bitmap?>() {
                override fun onLoadStarted(placeholder: Drawable?) {
                    bitmapLoadListener.onLoadStart()
                }

                override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap?>?) {
                    bitmapLoadListener.onResourceReady(resource)
                }
            })
        } catch (e: Exception) {
            bitmapLoadListener.onError("加载失败")
            showToast(e.toString())
        }
    }

    override fun canTurnPageByVolume(): Boolean {
        return SpUtil.getBooleanValue("volume_turn_page", true)
    }
}

项目地址

源码

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,402评论 6 499
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,377评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,483评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,165评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,176评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,146评论 1 297
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,032评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,896评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,311评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,536评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,696评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,413评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,008评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,659评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,815评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,698评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,592评论 2 353

推荐阅读更多精彩内容