這是一款基于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ù)填坑中...)
預覽
項目架構
本項目采用采用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)
}
}