kotlin開源項目——免費在線小說閱讀器

這是一款基于kotlin的免費Android小說應用追迟。
本項目參考了https://github.com/390057892/reader 項目勾效,
將原項目的閱讀功能單獨打包成了readerlib庫來使用东且,摒除了原項目繁雜的依賴關系姐霍,可以方便的應用于其他項目墩崩。

在這里對原作者表示衷心的感謝!

項目明細

環(huán)境:

android studio 3.5
kotlin_version 1.3.61
kotlin_coroutines 1.3.3

特點:

本項目采用Serverless模式進行開發(fā)规肴,數據源來自于jsoup抓取網頁數據捶闸,功能大部分使用Android官方數據庫框架Room在本地實現,使用了kotlin協(xié)程和Rxjava來處理異步數據奏纪,開箱即用鉴嗤。具有很好的學習和參考價值。

主要功能

1.在線搜索序调,分類瀏覽醉锅,查看詳情
2.加入本地書架閱讀
3.閱讀偏好設置
4.記錄搜索歷史
5.記錄閱讀進度
6.添加本地書籍到書架
7.管理書架
(更多新增功能、UI優(yōu)化发绢、bug修復還在陸續(xù)填坑中...)

預覽

device-2020-02-20-141340.png

device-2020-02-20-141601.png

device-2020-02-20-141616.png

項目架構

本項目采用采用MVC架構進行開發(fā)硬耍,所有核心功能都由BookRegistory抽象類實現,開發(fā)者只需要重寫相關抽象方法即可實現對應的功能边酒,將核心模塊完全解耦出來经柴,方便功能遷移和二次開發(fā)。

關鍵代碼

BookRegistory模塊

/**
 * Created by newbiechen on 17-5-8.
 * 存儲關于書籍內容的信息(CollBook(收藏書籍),BookChapter(書籍列表),ChapterInfo(書籍章節(jié)),BookRecord(記錄),BookSignTable書簽)
 */
abstract class BookRepository {
    /**
     * 保存閱讀記錄
     */
    abstract fun saveBookRecord(mBookRecord: ReadRecordBean)

    /**
     * 獲取閱讀記錄
     */
    abstract fun getBookRecord(bookUrl: String, readRecordListener: OnReadRecordListener)

    /**
     * 獲取章節(jié)列表
     */
    abstract fun chapterBeans(mCollBook: BookBean, onChaptersListener: OnChaptersListener, start: Int = 0)

    /**
     * 獲取章節(jié)內容
     */
    abstract fun requestChapterContents(mCollBook: BookBean, requestChapters: List<ChapterBean?>, onChaptersListener: OnChaptersListener)

    abstract fun saveBookChaptersWithAsync(bookChapterBeanList: List<ChapterBean>, mCollBook: BookBean)
    /**
     * 存儲章節(jié)
     *
     * @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)
                        //存儲章節(jié)內容到本地文件
                        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("本章節(jié)書簽已經存在")
                    }
                }
            }
        } 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,496評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件陋气,死亡現場離奇詭異,居然都是意外死亡引润,警方通過查閱死者的電腦和手機巩趁,發(fā)現死者居然都...
    沈念sama閱讀 92,407評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來淳附,“玉大人议慰,你說我怎么就攤上這事∨铮” “怎么了别凹?”我有些...
    開封第一講書人閱讀 162,632評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長缆毁。 經常有香客問我番川,道長到涂,這世上最難降的妖魔是什么脊框? 我笑而不...
    開封第一講書人閱讀 58,180評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮践啄,結果婚禮上浇雹,老公的妹妹穿的比我還像新娘。我一直安慰自己屿讽,他們只是感情好昭灵,可當我...
    茶點故事閱讀 67,198評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著伐谈,像睡著了一般烂完。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上诵棵,一...
    開封第一講書人閱讀 51,165評論 1 299
  • 那天抠蚣,我揣著相機與錄音,去河邊找鬼履澳。 笑死嘶窄,一個胖子當著我的面吹牛,可吹牛的內容都是我干的距贷。 我是一名探鬼主播柄冲,決...
    沈念sama閱讀 40,052評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼忠蝗!你這毒婦竟也來了现横?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 38,910評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎戒祠,沒想到半個月后晦攒,有當地人在樹林里發(fā)現了一具尸體,經...
    沈念sama閱讀 45,324評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡得哆,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,542評論 2 332
  • 正文 我和宋清朗相戀三年脯颜,在試婚紗的時候發(fā)現自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片贩据。...
    茶點故事閱讀 39,711評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡栋操,死狀恐怖,靈堂內的尸體忽然破棺而出饱亮,到底是詐尸還是另有隱情矾芙,我是刑警寧澤,帶...
    沈念sama閱讀 35,424評論 5 343
  • 正文 年R本政府宣布近上,位于F島的核電站剔宪,受9級特大地震影響,放射性物質發(fā)生泄漏壹无。R本人自食惡果不足惜葱绒,卻給世界環(huán)境...
    茶點故事閱讀 41,017評論 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望斗锭。 院中可真熱鬧地淀,春花似錦、人聲如沸岖是。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,668評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽豺撑。三九已至烈疚,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間聪轿,已是汗流浹背爷肝。 一陣腳步聲響...
    開封第一講書人閱讀 32,823評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留屹电,地道東北人阶剑。 一個月前我還...
    沈念sama閱讀 47,722評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像危号,于是被迫代替她去往敵國和親牧愁。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,611評論 2 353

推薦閱讀更多精彩內容