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:
@@ -42,6 +42,7 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion = "1.5.14"
|
kotlinCompilerExtensionVersion = "1.5.14"
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// Логирование каждого HTTP-запроса — только в debug. В релизе это лишний
|
||||||
|
// оверхед на каждый вызов и утечка URL/деталей в logcat.
|
||||||
|
if (com.radiola.BuildConfig.DEBUG) {
|
||||||
|
addInterceptor(HttpLoggingInterceptor().apply {
|
||||||
level = HttpLoggingInterceptor.Level.BASIC
|
level = HttpLoggingInterceptor.Level.BASIC
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
private val playerListener = object : Player.Listener {
|
||||||
override fun onIsPlayingChanged(playing: Boolean) {
|
override fun onIsPlayingChanged(playing: Boolean) {
|
||||||
_isPlaying.value = playing
|
_isPlaying.value = playing
|
||||||
|
// Поллер позиции крутится ТОЛЬКО во время игры — раньше цикл 2 Гц
|
||||||
|
// работал всю сессию вхолостую (буст main-loop / батарея).
|
||||||
|
if (playing) startPositionPolling() else stopPositionPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
when (playbackState) {
|
when (playbackState) {
|
||||||
Player.STATE_READY -> {
|
Player.STATE_READY -> {
|
||||||
val dur = duration
|
val dur = exoPlayer?.duration ?: C.TIME_UNSET
|
||||||
_durationMs.value = if (dur == C.TIME_UNSET) 0L else dur
|
_durationMs.value = if (dur == C.TIME_UNSET) 0L else dur
|
||||||
}
|
}
|
||||||
Player.STATE_ENDED -> {
|
Player.STATE_ENDED -> {
|
||||||
_isPlaying.value = false
|
_isPlaying.value = false
|
||||||
_positionMs.value = _durationMs.value
|
_positionMs.value = _durationMs.value
|
||||||
|
stopPositionPolling()
|
||||||
}
|
}
|
||||||
else -> Unit
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user