MainActivity: rework layout to avoid lag
ScrollView + RecyclerView lead to very slow loading times for the recycler, like a few seconds on the biggest Medusae pages. Turns out it binds every ViewHolder instantly, losing all the recycling behavior! Following some guidelines on StackOverflow fixed this, because official docs could not.
This commit is contained in:
parent
57854e56dc
commit
3cba46ad5d
|
@ -25,6 +25,7 @@ android {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
@ -42,6 +43,7 @@ dependencies {
|
||||||
implementation 'com.google.android.material:material:1.4.0'
|
implementation 'com.google.android.material:material:1.4.0'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
|
||||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
|
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
|
||||||
|
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package dev.lowrespalmtree.comet
|
package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.text.SpannableString
|
import android.text.SpannableString
|
||||||
import android.text.style.UnderlineSpan
|
import android.text.style.UnderlineSpan
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
@ -12,6 +13,8 @@ import dev.lowrespalmtree.comet.databinding.*
|
||||||
class ContentAdapter(private var content: List<Line>, private val listener: ContentAdapterListen) :
|
class ContentAdapter(private var content: List<Line>, private val listener: ContentAdapterListen) :
|
||||||
RecyclerView.Adapter<ContentAdapter.ContentViewHolder>() {
|
RecyclerView.Adapter<ContentAdapter.ContentViewHolder>() {
|
||||||
|
|
||||||
|
private var lastLineCount = 0
|
||||||
|
|
||||||
interface ContentAdapterListen {
|
interface ContentAdapterListen {
|
||||||
fun onLinkClick(url: String)
|
fun onLinkClick(url: String)
|
||||||
}
|
}
|
||||||
|
@ -35,7 +38,6 @@ class ContentAdapter(private var content: List<Line>, private val listener: Cont
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContentViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContentViewHolder {
|
||||||
Log.d(TAG, "onCreateViewHolder: type $viewType")
|
|
||||||
return LayoutInflater.from(parent.context).let {
|
return LayoutInflater.from(parent.context).let {
|
||||||
when (viewType) {
|
when (viewType) {
|
||||||
TYPE_EMPTY -> ContentViewHolder.Empty(GemtextEmptyBinding.inflate(it))
|
TYPE_EMPTY -> ContentViewHolder.Empty(GemtextEmptyBinding.inflate(it))
|
||||||
|
@ -54,7 +56,6 @@ class ContentAdapter(private var content: List<Line>, private val listener: Cont
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ContentViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ContentViewHolder, position: Int) {
|
||||||
Log.d(TAG, "onBindViewHolder: position $position")
|
|
||||||
val line = content[position]
|
val line = content[position]
|
||||||
when (holder) {
|
when (holder) {
|
||||||
is ContentViewHolder.Paragraph -> holder.binding.textView.text =
|
is ContentViewHolder.Paragraph -> holder.binding.textView.text =
|
||||||
|
@ -75,9 +76,28 @@ class ContentAdapter(private var content: List<Line>, private val listener: Cont
|
||||||
|
|
||||||
override fun getItemCount(): Int = content.size
|
override fun getItemCount(): Int = content.size
|
||||||
|
|
||||||
fun setContent(content: List<Line>) {
|
/**
|
||||||
this.content = content
|
* Replace the content rendered by the recycler.
|
||||||
notifyDataSetChanged()
|
*
|
||||||
|
* The new content list may or may not be the same object as the previous one, we don't
|
||||||
|
* assume anything. The assumptions this function do however are:
|
||||||
|
* - If the new content is empty, we are about to load new content, so clear the recycler.
|
||||||
|
* - If it's longer than before, we received new streamed content, so *append* data.
|
||||||
|
* - If it's shorter or the same size than before, we do not notify anything and let the caller
|
||||||
|
* manage the changes itself.
|
||||||
|
*/
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
|
fun setContent(newContent: List<Line>) {
|
||||||
|
content = newContent.toList() // Shallow copy to avoid concurrent update issues.
|
||||||
|
if (content.isEmpty()) {
|
||||||
|
Log.d(TAG, "setContent: empty content")
|
||||||
|
notifyDataSetChanged()
|
||||||
|
} else if (content.size > lastLineCount) {
|
||||||
|
val numAdded = content.size - lastLineCount
|
||||||
|
Log.d(TAG, "setContent: added $numAdded items")
|
||||||
|
notifyItemRangeInserted(lastLineCount, numAdded)
|
||||||
|
}
|
||||||
|
lastLineCount = content.size
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class ContentViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
sealed class ContentViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
|
|
@ -41,6 +41,7 @@ fun parseData(
|
||||||
channel.send(line)
|
channel.send(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
channel.close()
|
||||||
}
|
}
|
||||||
return channel
|
return channel
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.Intent.ACTION_VIEW
|
import android.content.Intent.ACTION_VIEW
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
@ -18,6 +19,7 @@ import androidx.lifecycle.viewModelScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import dev.lowrespalmtree.comet.databinding.ActivityMainBinding
|
import dev.lowrespalmtree.comet.databinding.ActivityMainBinding
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
|
@ -27,6 +29,8 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
|
||||||
private lateinit var pageViewModel: PageViewModel
|
private lateinit var pageViewModel: PageViewModel
|
||||||
private lateinit var adapter: ContentAdapter
|
private lateinit var adapter: ContentAdapter
|
||||||
|
|
||||||
|
private val currentUrl get() = binding.addressBar.text
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
@SuppressLint("SetTextI18n")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -50,8 +54,11 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pageViewModel.linesLiveData.observe(this, { adapter.setContent(it) })
|
binding.contentSwipeLayout.setOnRefreshListener { openUrl(currentUrl.toString()) }
|
||||||
pageViewModel.alertLiveData.observe(this, { alert(it) })
|
|
||||||
|
pageViewModel.state.observe(this, { updateState(it) })
|
||||||
|
pageViewModel.lines.observe(this, { updateLines(it) })
|
||||||
|
pageViewModel.alert.observe(this, { alert(it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLinkClick(url: String) {
|
override fun onLinkClick(url: String) {
|
||||||
|
@ -63,29 +70,68 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
|
||||||
var uri = Uri.parse(url)
|
var uri = Uri.parse(url)
|
||||||
if (!uri.isAbsolute) {
|
if (!uri.isAbsolute) {
|
||||||
uri = if (!base.isNullOrEmpty()) joinUrls(base, url) else toGeminiUri(uri)
|
uri = if (!base.isNullOrEmpty()) joinUrls(base, url) else toGeminiUri(uri)
|
||||||
Log.d(TAG, "openUrl: '$url' - '$base' - '$uri'")
|
|
||||||
Log.d(TAG, "openUrl: ${uri.authority} - ${uri.path} - ${uri.query}")
|
|
||||||
binding.addressBar.setText(uri.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
when (uri.scheme) {
|
when (uri.scheme) {
|
||||||
"gemini" -> pageViewModel.sendGeminiRequest(uri)
|
"gemini" -> {
|
||||||
else -> startActivity(Intent(ACTION_VIEW, uri))
|
binding.addressBar.setText(uri.toString())
|
||||||
|
pageViewModel.sendGeminiRequest(uri)
|
||||||
|
}
|
||||||
|
else -> openUnknownScheme(uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun alert(message: String) {
|
private fun updateState(state: PageViewModel.State) {
|
||||||
AlertDialog.Builder(this)
|
Log.d(TAG, "updateState: $state")
|
||||||
.setTitle(R.string.alert_title)
|
when (state) {
|
||||||
|
PageViewModel.State.IDLE -> {
|
||||||
|
binding.contentProgressBar.hide()
|
||||||
|
binding.contentSwipeLayout.isRefreshing = false
|
||||||
|
}
|
||||||
|
PageViewModel.State.CONNECTING -> {
|
||||||
|
binding.contentProgressBar.show()
|
||||||
|
}
|
||||||
|
PageViewModel.State.RECEIVING -> {
|
||||||
|
binding.contentSwipeLayout.isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLines(lines: List<Line>) {
|
||||||
|
Log.d(TAG, "updateLines: ${lines.size} lines")
|
||||||
|
adapter.setContent(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alert(message: String, title: String? = null) {
|
||||||
|
val builder = AlertDialog.Builder(this)
|
||||||
|
if (title != null)
|
||||||
|
builder.setTitle(title)
|
||||||
|
else
|
||||||
|
builder.setTitle(title ?: R.string.alert_title)
|
||||||
|
builder
|
||||||
.setMessage(message)
|
.setMessage(message)
|
||||||
.create()
|
.create()
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun openUnknownScheme(uri: Uri) {
|
||||||
|
try {
|
||||||
|
startActivity(Intent(ACTION_VIEW, uri))
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
alert("Can't open this URL.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PageViewModel : ViewModel() {
|
class PageViewModel : ViewModel() {
|
||||||
private var lines = ArrayList<Line>()
|
private var requestJob: Job? = null
|
||||||
val linesLiveData: MutableLiveData<List<Line>> by lazy { MutableLiveData<List<Line>>() }
|
val state: MutableLiveData<State> by lazy { MutableLiveData<State>(State.IDLE) }
|
||||||
val alertLiveData: MutableLiveData<String> by lazy { MutableLiveData<String>() }
|
private var linesList = ArrayList<Line>()
|
||||||
|
val lines: MutableLiveData<List<Line>> by lazy { MutableLiveData<List<Line>>() }
|
||||||
|
val alert: MutableLiveData<String> by lazy { MutableLiveData<String>() }
|
||||||
|
|
||||||
|
enum class State {
|
||||||
|
IDLE, CONNECTING, RECEIVING
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a request against this URI.
|
* Perform a request against this URI.
|
||||||
|
@ -93,42 +139,57 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
|
||||||
* The URI must be valid, absolute and with a gemini scheme.
|
* The URI must be valid, absolute and with a gemini scheme.
|
||||||
*/
|
*/
|
||||||
fun sendGeminiRequest(uri: Uri) {
|
fun sendGeminiRequest(uri: Uri) {
|
||||||
Log.d(TAG, "sendRequest: $uri")
|
Log.d(TAG, "sendRequest: URI \"$uri\"")
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
state.postValue(State.CONNECTING)
|
||||||
|
requestJob?.apply { if (isActive) cancel() }
|
||||||
|
requestJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
val response = try {
|
val response = try {
|
||||||
val request = Request(uri)
|
val request = Request(uri)
|
||||||
val socket = request.connect()
|
val socket = request.connect()
|
||||||
val channel = request.proceed(socket, this)
|
val channel = request.proceed(socket, this)
|
||||||
Response.from(channel, viewModelScope)
|
Response.from(channel, viewModelScope)
|
||||||
} catch (e: UnknownHostException) {
|
} catch (e: UnknownHostException) {
|
||||||
alertLiveData.postValue("Unknown host \"${uri.authority}\".")
|
signalError("Unknown host \"${uri.authority}\".")
|
||||||
return@launch
|
return@launch
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "sendGeminiRequest coroutine: ${e.stackTraceToString()}")
|
Log.e(TAG, "sendGeminiRequest coroutine: ${e.stackTraceToString()}")
|
||||||
alertLiveData.postValue("Oops! Whatever we tried to do failed!")
|
signalError("Oops! Whatever we tried to do failed!")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
alertLiveData.postValue("Can't parse server response.")
|
signalError("Can't parse server response.")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.i(TAG, "sendRequest: got ${response.code} with meta \"${response.meta}\"")
|
Log.i(TAG, "sendRequest: got ${response.code} with meta \"${response.meta}\"")
|
||||||
when (response.code) {
|
when (response.code) {
|
||||||
Response.Code.SUCCESS -> handleRequestSuccess(response)
|
Response.Code.SUCCESS -> handleRequestSuccess(response)
|
||||||
else -> alertLiveData.postValue("Can't handle code ${response.code}.")
|
else -> signalError("Can't handle code ${response.code}.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signalError(message: String) {
|
||||||
|
alert.postValue(message)
|
||||||
|
state.postValue(State.IDLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleRequestSuccess(response: Response) {
|
private suspend fun handleRequestSuccess(response: Response) {
|
||||||
lines.clear()
|
state.postValue(State.RECEIVING)
|
||||||
|
linesList.clear()
|
||||||
|
lines.postValue(linesList)
|
||||||
val charset = Charset.defaultCharset()
|
val charset = Charset.defaultCharset()
|
||||||
|
var lastUpdate = System.currentTimeMillis()
|
||||||
for (line in parseData(response.data, charset, viewModelScope)) {
|
for (line in parseData(response.data, charset, viewModelScope)) {
|
||||||
lines.add(line)
|
linesList.add(line)
|
||||||
linesLiveData.postValue(lines)
|
val time = System.currentTimeMillis()
|
||||||
|
if (time - lastUpdate >= 100) { // Throttle to 100ms the recycler view updates…
|
||||||
|
lines.postValue(linesList)
|
||||||
|
lastUpdate = time
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
lines.postValue(linesList)
|
||||||
|
state.postValue(State.IDLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -50,6 +50,7 @@ class Request(private val uri: Uri) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.d(TAG, "proceed coroutine: reading completed")
|
Log.d(TAG, "proceed coroutine: reading completed")
|
||||||
|
channel.close()
|
||||||
}
|
}
|
||||||
return channel
|
return channel
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,6 +70,7 @@ class Response(val code: Code, val meta: String, val data: Channel<ByteArray>) {
|
||||||
}
|
}
|
||||||
// Forward all incoming data to the Response channel.
|
// Forward all incoming data to the Response channel.
|
||||||
channel.consumeEach { responseChannel.send(it) }
|
channel.consumeEach { responseChannel.send(it) }
|
||||||
|
responseChannel.close()
|
||||||
}
|
}
|
||||||
// Return the response here; this stops consuming the channel from this for-loop so
|
// Return the response here; this stops consuming the channel from this for-loop so
|
||||||
// that the coroutine above can take care of it.
|
// that the coroutine above can take care of it.
|
||||||
|
|
|
@ -24,28 +24,48 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_margin="4dp"
|
android:layout_margin="4dp"
|
||||||
android:ems="10"
|
|
||||||
android:hint="@string/url"
|
android:hint="@string/url"
|
||||||
android:imeOptions="actionDone|actionGo"
|
android:imeOptions="actionDone|actionGo"
|
||||||
android:importantForAutofill="no"
|
android:importantForAutofill="no"
|
||||||
android:inputType="textUri"
|
android:inputType="textUri"
|
||||||
android:text="" />
|
android:text=""
|
||||||
|
tools:ignore="TextContrastCheck" />
|
||||||
|
|
||||||
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||||
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
<androidx.core.widget.NestedScrollView
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/scroll_view"
|
android:id="@+id/content_swipe_layout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
|
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<RelativeLayout
|
||||||
android:id="@+id/content_recycler"
|
android:id="@+id/content_inner_layout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical" />
|
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||||
</androidx.core.widget.NestedScrollView>
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/content_recycler"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<androidx.core.widget.ContentLoadingProgressBar
|
||||||
|
android:id="@+id/content_progress_bar"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="16dp"
|
||||||
|
android:layout_alignParentTop="true"
|
||||||
|
android:layout_marginTop="-8dp"
|
||||||
|
android:indeterminate="true"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -2,5 +2,5 @@
|
||||||
<Space xmlns:android="http://schemas.android.com/apk/res/android"
|
<Space xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="8dp"
|
android:layout_height="8dp"
|
||||||
android:background="@color/black">
|
android:background="@color/black"
|
||||||
</Space>
|
android:orientation="vertical"></Space>
|
Reference in a new issue