perf(android): ленивый плеер записей, O(n) merge каталога, @Immutable, лог только в debug

- RecordingPlaybackController: ExoPlayer создаётся лениво (на первый play) и
  освобождается в stop() — раньше второй плеер висел в памяти всю сессию у каждого.
  Поллер позиции 500мс крутится только во время игры (был вечный 2 Гц main-loop).
- StationRepositoryImpl.refreshStations: merge каталога O(n) через apiById/apiByName
  индексы вместо .find на каждую станцию (было O(n²) ~700×700 на холодном старте).
  Убраны verbose Log.d-трейсы (оставлен Log.e/.w на ошибки).
- Track/Station/StreamQuality помечены @Immutable — read-only модели, иначе
  списки tags/qualities делали класс нестабильным → лишние рекомпозиции списков.
- HttpLoggingInterceptor только при BuildConfig.DEBUG (включён buildConfig feature):
  в релизе нет оверхеда на каждый запрос и утечки URL в logcat.
This commit is contained in:
nk
2026-06-06 17:13:48 +03:00
parent f423344d13
commit 84c2b33473
6 changed files with 80 additions and 50 deletions

View File

@@ -42,6 +42,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.14" kotlinCompilerExtensionVersion = "1.5.14"

View File

@@ -38,13 +38,11 @@ class StationRepositoryImpl @Inject constructor(
} }
override suspend fun refreshStations(): Result<Unit> = withContext(Dispatchers.IO) { override suspend fun refreshStations(): Result<Unit> = withContext(Dispatchers.IO) {
android.util.Log.d("StationRepo", "refreshStations() called")
// Тяжёлый парс stations.json (~700) + сетевые вызовы + запись в Room — // Тяжёлый парс stations.json (~700) + сетевые вызовы + запись в Room —
// на IO, а не на главном потоке (был риск jank/ANR при холодном старте). // на IO, а не на главном потоке (был риск jank/ANR при холодном старте).
try { try {
// 1. Load local stations from assets // 1. Load local stations from assets
val localStations = localDataSource.loadStations() val localStations = localDataSource.loadStations()
android.util.Log.d("StationRepo", "Loaded ${localStations.size} local stations")
val localGroups = localDataSource.loadGroups() val localGroups = localDataSource.loadGroups()
// 2. Try to enrich with Record API data (covers, streams, tags) // 2. Try to enrich with Record API data (covers, streams, tags)
@@ -56,14 +54,16 @@ class StationRepositoryImpl @Inject constructor(
// Локальные id (1,2,3…) не совпадают с id Record-каталога, и в ассетах // Локальные id (1,2,3…) не совпадают с id Record-каталога, и в ассетах
// нет prefix — поэтому сопоставляем сначала по id, затем по названию // нет prefix — поэтому сопоставляем сначала по id, затем по названию
// (стабильный общий ключ), иначе обложки/потоки не подтягиваются. // (стабильный общий ключ), иначе обложки/потоки не подтягиваются.
// Индексы строим один раз (было O(n²): .find по ~700 на каждую из ~700).
// asReversed+associateBy сохраняет ПЕРВЫЙ по имени (как делал .find).
val apiById = apiStations.associateBy { it.id }
val apiByName = apiStations.asReversed()
.associateBy { it.name.trim().lowercase() }
val merged = localStations.map { local -> val merged = localStations.map { local ->
// Обложки/потоки из Record API — только для станций сети Radio Record. // Обложки/потоки из Record API — только для станций сети Radio Record.
// Иначе чужим сетям (DFM, HitFM и т.д.) цеплялись бы обложки Record. // Иначе чужим сетям (DFM, HitFM и т.д.) цеплялись бы обложки Record.
val apiStation = if (local.source == "record") { val apiStation = if (local.source == "record") {
apiStations.find { it.id == local.id } apiById[local.id] ?: apiByName[local.name.trim().lowercase()]
?: apiStations.find {
it.name.trim().equals(local.name.trim(), ignoreCase = true)
}
} else null } else null
if (apiStation != null) { if (apiStation != null) {
val domain = apiStation.toDomain() val domain = apiStation.toDomain()
@@ -83,7 +83,6 @@ class StationRepositoryImpl @Inject constructor(
// 4. Save to DB. Сохраняем текущие отметки «избранное», иначе REPLACE // 4. Save to DB. Сохраняем текущие отметки «избранное», иначе REPLACE
// в insertAll затрёт их при каждом пересоздании каталога (на старте). // в insertAll затрёт их при каждом пересоздании каталога (на старте).
val favoriteIds = db.stationDao().getFavoriteIdsOnce().toSet() val favoriteIds = db.stationDao().getFavoriteIdsOnce().toSet()
android.util.Log.d("StationRepo", "Saving ${merged.size} merged stations to DB (избранных сохранено: ${favoriteIds.size})")
val entities = merged.mapIndexed { index, station -> val entities = merged.mapIndexed { index, station ->
StationEntity( StationEntity(
id = station.id, id = station.id,
@@ -100,7 +99,6 @@ class StationRepositoryImpl @Inject constructor(
) )
} }
db.stationDao().insertAll(entities) db.stationDao().insertAll(entities)
android.util.Log.d("StationRepo", "Inserted ${entities.size} stations into DB")
// 4b. Скрываем станции, которые бэкенд пометил оффлайн (мёртвые потоки). // 4b. Скрываем станции, которые бэкенд пометил оффлайн (мёртвые потоки).
// Если бэкенд недоступен — оставляем как есть (фолбэк на статичный enabled). // Если бэкенд недоступен — оставляем как есть (фолбэк на статичный enabled).
@@ -108,7 +106,6 @@ class StationRepositoryImpl @Inject constructor(
val offlineIds = radiolaApi.getOfflineStationIds() val offlineIds = radiolaApi.getOfflineStationIds()
if (offlineIds.isNotEmpty()) { if (offlineIds.isNotEmpty()) {
db.stationDao().deleteByIds(offlineIds) db.stationDao().deleteByIds(offlineIds)
android.util.Log.d("StationRepo", "Скрыто оффлайн-станций: ${offlineIds.size}")
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w("StationRepo", "Не удалось получить offline-id: ${e.message}") android.util.Log.w("StationRepo", "Не удалось получить offline-id: ${e.message}")

View File

@@ -68,9 +68,15 @@ object AppModule {
fun provideBaseOkHttpClient(): OkHttpClient = OkHttpClient.Builder() fun provideBaseOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS) .connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply { .apply {
level = HttpLoggingInterceptor.Level.BASIC // Логирование каждого HTTP-запроса — только в debug. В релизе это лишний
}) // оверхед на каждый вызов и утечка URL/деталей в logcat.
if (com.radiola.BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
}
}
.build() .build()
@Provides @Provides

View File

@@ -1,5 +1,11 @@
package com.radiola.domain.model package com.radiola.domain.model
import androidx.compose.runtime.Immutable
// @Immutable: модели read-only (заполняются один раз из БД/маппера и не мутируются).
// Список tags/qualities иначе делает класс «нестабильным» для Compose → лишние
// рекомпозиции списков станций. Помечаем явно — компилятор сможет пропускать.
@Immutable
data class Station( data class Station(
val id: Int, val id: Int,
val name: String, val name: String,
@@ -15,6 +21,7 @@ data class Station(
) )
/** Один вариант качества потока станции. */ /** Один вариант качества потока станции. */
@Immutable
data class StreamQuality( data class StreamQuality(
val bitrate: Int, // kbps val bitrate: Int, // kbps
val url: String, val url: String,

View File

@@ -1,5 +1,8 @@
package com.radiola.domain.model package com.radiola.domain.model
import androidx.compose.runtime.Immutable
@Immutable
data class Track( data class Track(
val artist: String, val artist: String,
val song: String, val song: String,

View File

@@ -50,48 +50,63 @@ class RecordingPlaybackController @Inject constructor(
.setConstantBitrateSeekingEnabled(true) .setConstantBitrateSeekingEnabled(true)
.setConstantBitrateSeekingAlwaysEnabled(true) .setConstantBitrateSeekingAlwaysEnabled(true)
private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context) // Плеер создаём ЛЕНИВО — запись играют редко, а раньше второй ExoPlayer висел
.setMediaSourceFactory(DefaultMediaSourceFactory(context, extractorsFactory)) // в памяти всю сессию у каждого. Освобождаем в stop(): обычно после остановки
.build().apply { // пользователь уходит с экрана записей, держать декодер/буферы незачем.
addListener(object : Player.Listener { private var exoPlayer: ExoPlayer? = null
override fun onIsPlayingChanged(playing: Boolean) {
_isPlaying.value = playing
}
override fun onPlaybackStateChanged(playbackState: Int) { private val playerListener = object : Player.Listener {
when (playbackState) { override fun onIsPlayingChanged(playing: Boolean) {
Player.STATE_READY -> { _isPlaying.value = playing
val dur = duration // Поллер позиции крутится ТОЛЬКО во время игры — раньше цикл 2 Гц
_durationMs.value = if (dur == C.TIME_UNSET) 0L else dur // работал всю сессию вхолостую (буст main-loop / батарея).
} if (playing) startPositionPolling() else stopPositionPolling()
Player.STATE_ENDED -> { }
_isPlaying.value = false
_positionMs.value = _durationMs.value override fun onPlaybackStateChanged(playbackState: Int) {
} when (playbackState) {
else -> Unit Player.STATE_READY -> {
val dur = exoPlayer?.duration ?: C.TIME_UNSET
_durationMs.value = if (dur == C.TIME_UNSET) 0L else dur
} }
Player.STATE_ENDED -> {
_isPlaying.value = false
_positionMs.value = _durationMs.value
stopPositionPolling()
}
else -> Unit
} }
}) }
} }
private var positionPollingJob: Job? = null private var positionPollingJob: Job? = null
init { private fun ensurePlayer(): ExoPlayer =
// Цикл обновления позиции каждые 500мс exoPlayer ?: ExoPlayer.Builder(context)
.setMediaSourceFactory(DefaultMediaSourceFactory(context, extractorsFactory))
.build()
.also { it.addListener(playerListener); exoPlayer = it }
private fun startPositionPolling() {
if (positionPollingJob?.isActive == true) return
positionPollingJob = scope.launch { positionPollingJob = scope.launch {
while (isActive) { while (isActive) {
if (exoPlayer.isPlaying) { val p = exoPlayer ?: break
_positionMs.value = exoPlayer.currentPosition if (p.isPlaying) {
val dur = exoPlayer.duration _positionMs.value = p.currentPosition
if (dur != C.TIME_UNSET) { val dur = p.duration
_durationMs.value = dur if (dur != C.TIME_UNSET) _durationMs.value = dur
}
} }
delay(500) delay(500)
} }
} }
} }
private fun stopPositionPolling() {
positionPollingJob?.cancel()
positionPollingJob = null
}
/** /**
* Начать воспроизведение записи. Сначала останавливает радио. * Начать воспроизведение записи. Сначала останавливает радио.
*/ */
@@ -104,25 +119,24 @@ class RecordingPlaybackController @Inject constructor(
_positionMs.value = 0L _positionMs.value = 0L
_durationMs.value = recording.duration ?: 0L _durationMs.value = recording.duration ?: 0L
val player = ensurePlayer()
val mediaItem = MediaItem.fromUri(android.net.Uri.fromFile(File(recording.filePath))) val mediaItem = MediaItem.fromUri(android.net.Uri.fromFile(File(recording.filePath)))
exoPlayer.setMediaItem(mediaItem) player.setMediaItem(mediaItem)
exoPlayer.prepare() player.prepare()
exoPlayer.play() player.play()
} }
/** Переключить паузу/воспроизведение. */ /** Переключить паузу/воспроизведение. */
fun togglePlayPause() { fun togglePlayPause() {
if (exoPlayer.isPlaying) { val player = exoPlayer ?: return
exoPlayer.pause() if (player.isPlaying) player.pause() else player.play()
} else {
exoPlayer.play()
}
} }
/** Перейти к позиции в мс. */ /** Перейти к позиции в мс. */
fun seekTo(ms: Long) { fun seekTo(ms: Long) {
val player = exoPlayer ?: return
val target = ms.coerceIn(0L, _durationMs.value.coerceAtLeast(1L)) val target = ms.coerceIn(0L, _durationMs.value.coerceAtLeast(1L))
exoPlayer.seekTo(target) player.seekTo(target)
_positionMs.value = target _positionMs.value = target
} }
@@ -131,9 +145,11 @@ class RecordingPlaybackController @Inject constructor(
seekTo(_positionMs.value + deltaMs) seekTo(_positionMs.value + deltaMs)
} }
/** Остановить воспроизведение и сбросить текущую запись. */ /** Остановить воспроизведение, освободить плеер и сбросить текущую запись. */
fun stop() { fun stop() {
exoPlayer.stop() stopPositionPolling()
exoPlayer?.release()
exoPlayer = null
_current.value = null _current.value = null
_isPlaying.value = false _isPlaying.value = false
_positionMs.value = 0L _positionMs.value = 0L