perf(android): батарея и плавность — gate FFT, изоляция рекомпозиции, поллинг на паузе
- AudioSpectrumAnalyzer: FFT считается ТОЛЬКО когда открыт плеер (флаг active); раньше ~86 FFT/с молотили всегда при проигрывании (даже экран выкл) — главный пожиратель батареи. Включается из VisualizerHost через DisposableEffect. - Спектр (45/с) собирается в leaf VisualizerHost, а не на верху PlayerBottomSheet — весь плеер больше не рекомпозится 45 раз/сек. - now-playing поллинг (5с) останавливается на паузе (isPlaying.collectLatest) — раньше на паузе зря дёргали сеть каждые 5с. - PlayerService.onDestroy отменяет serviceScope (singleton-плеер НЕ релизим). - refreshStations (парс ~700 станций + сеть + Room) уведён на Dispatchers.IO с главного потока (jank/ANR на старте). - Coil ImageLoader: память 25% + диск 100МБ (обложки не перекачиваются каждую сессию). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,30 @@
|
|||||||
package com.radiola
|
package com.radiola
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.disk.DiskCache
|
||||||
|
import coil.memory.MemoryCache
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class RadiolaApplication : Application()
|
class RadiolaApplication : Application(), ImageLoaderFactory {
|
||||||
|
|
||||||
|
// Явная настройка кэша Coil: иначе обложки (iTunes/Record/own-CDN) перекачиваются
|
||||||
|
// каждую сессию, и нет контроля над памятью. Память 25% + диск 100МБ.
|
||||||
|
override fun newImageLoader(): ImageLoader =
|
||||||
|
ImageLoader.Builder(this)
|
||||||
|
.memoryCache {
|
||||||
|
MemoryCache.Builder(this)
|
||||||
|
.maxSizePercent(0.25)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.diskCache {
|
||||||
|
DiskCache.Builder()
|
||||||
|
.directory(cacheDir.resolve("image_cache"))
|
||||||
|
.maxSizeBytes(100L * 1024 * 1024)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.crossfade(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import com.radiola.data.remote.ApiMapper.toDomain
|
|||||||
import com.radiola.domain.model.Station
|
import com.radiola.domain.model.Station
|
||||||
import com.radiola.domain.model.StreamQuality
|
import com.radiola.domain.model.StreamQuality
|
||||||
import com.radiola.domain.repository.StationRepository
|
import com.radiola.domain.repository.StationRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -35,9 +37,11 @@ class StationRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun refreshStations(): Result<Unit> {
|
override suspend fun refreshStations(): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
android.util.Log.d("StationRepo", "refreshStations() called")
|
android.util.Log.d("StationRepo", "refreshStations() called")
|
||||||
return try {
|
// Тяжёлый парс stations.json (~700) + сетевые вызовы + запись в Room —
|
||||||
|
// на IO, а не на главном потоке (был риск jank/ANR при холодном старте).
|
||||||
|
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")
|
android.util.Log.d("StationRepo", "Loaded ${localStations.size} local stations")
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ class AudioSpectrumAnalyzer(
|
|||||||
private val _spectrum = MutableStateFlow(FloatArray(bands))
|
private val _spectrum = MutableStateFlow(FloatArray(bands))
|
||||||
val spectrum: StateFlow<FloatArray> = _spectrum
|
val spectrum: StateFlow<FloatArray> = _spectrum
|
||||||
|
|
||||||
|
// FFT считаем ТОЛЬКО когда есть наблюдатель (открыт плеер). Иначе ~86 FFT/с
|
||||||
|
// молотят впустую при фоновом проигрывании (экран выключен) — главный
|
||||||
|
// пожиратель батареи. Ставится из UI плеера (VisualizerHost).
|
||||||
|
@Volatile
|
||||||
|
var active: Boolean = false
|
||||||
|
|
||||||
// Меньше окно = меньше задержка реакции на удар (групповая задержка Hann ~окно/2)
|
// Меньше окно = меньше задержка реакции на удар (групповая задержка Hann ~окно/2)
|
||||||
// и чаще обновления. Лайвность держит автогейн, а не размер окна.
|
// и чаще обновления. Лайвность держит автогейн, а не размер окна.
|
||||||
private val fftSize = 1024
|
private val fftSize = 1024
|
||||||
@@ -100,6 +106,11 @@ class AudioSpectrumAnalyzer(
|
|||||||
|
|
||||||
override fun handleBuffer(buffer: ByteBuffer) {
|
override fun handleBuffer(buffer: ByteBuffer) {
|
||||||
if (!pcm16) return
|
if (!pcm16) return
|
||||||
|
// Нет наблюдателя — не тратим CPU на FFT (батарея при фоновом проигрывании).
|
||||||
|
if (!active) {
|
||||||
|
filled = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
val b = buffer.duplicate().order(ByteOrder.LITTLE_ENDIAN)
|
val b = buffer.duplicate().order(ByteOrder.LITTLE_ENDIAN)
|
||||||
val ch = channelCount
|
val ch = channelCount
|
||||||
val hop = fftSize / 2
|
val hop = fftSize / 2
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -346,6 +347,11 @@ class PlayerController @Inject constructor(
|
|||||||
_sleepRemainingMs.value = null
|
_sleepRemainingMs.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Включить/выключить расчёт спектра (FFT) — только пока открыт плеер. */
|
||||||
|
fun setSpectrumActive(active: Boolean) {
|
||||||
|
spectrumAnalyzer.active = active
|
||||||
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
exoPlayer.pause()
|
exoPlayer.pause()
|
||||||
}
|
}
|
||||||
@@ -361,6 +367,7 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
|
timerScope.cancel()
|
||||||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||||||
sleepSoundPlayer.stop()
|
sleepSoundPlayer.stop()
|
||||||
exoPlayer.release()
|
exoPlayer.release()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -158,6 +159,10 @@ class PlayerService : MediaSessionService() {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
mediaSession?.release()
|
mediaSession?.release()
|
||||||
mediaSession = null
|
mediaSession = null
|
||||||
|
// serviceScope — поле этого сервиса (пере-создаётся при рестарте), отменяем.
|
||||||
|
// playerController — @Singleton (переживает рестарт сервиса), его НЕ релизим:
|
||||||
|
// иначе новый PlayerService построит MediaSession на освобождённом плеере.
|
||||||
|
serviceScope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ fun PlayerBottomSheet(
|
|||||||
var selectedSound by remember { mutableStateOf<com.radiola.service.SleepSound?>(null) }
|
var selectedSound by remember { mutableStateOf<com.radiola.service.SleepSound?>(null) }
|
||||||
val currentQuality by viewModel.currentQuality.collectAsState()
|
val currentQuality by viewModel.currentQuality.collectAsState()
|
||||||
val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState()
|
val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState()
|
||||||
val spectrum by viewModel.spectrum.collectAsState()
|
|
||||||
val vizStyle by viewModel.visualizerStyle.collectAsState()
|
val vizStyle by viewModel.visualizerStyle.collectAsState()
|
||||||
|
|
||||||
val landscape = com.radiola.ui.util.isLandscape()
|
val landscape = com.radiola.ui.util.isLandscape()
|
||||||
@@ -188,10 +187,11 @@ fun PlayerBottomSheet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val visualizerSection: @Composable () -> Unit = {
|
val visualizerSection: @Composable () -> Unit = {
|
||||||
// Живой эквалайзер — вместо прогресс-бара (эфир нельзя перематывать)
|
// Живой эквалайзер. Спектр (45/с) собирается ВНУТРИ VisualizerHost —
|
||||||
com.radiola.ui.components.Visualizer(
|
// чтобы 45/с рекомпозиции не задевали весь плеер, только этот leaf.
|
||||||
style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle),
|
VisualizerHost(
|
||||||
levels = spectrum,
|
viewModel = viewModel,
|
||||||
|
vizStyle = vizStyle,
|
||||||
playing = isPlaying,
|
playing = isPlaying,
|
||||||
color = colors.accent,
|
color = colors.accent,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -614,6 +614,33 @@ private fun SleepRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaf-обёртка эквалайзера: сама собирает спектр (обновляется ~45/с) и включает
|
||||||
|
* расчёт FFT только пока скомпонована (открыт плеер) — это и изолирует частые
|
||||||
|
* рекомпозиции от остального плеера, и гасит FFT в фоне (батарея).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun VisualizerHost(
|
||||||
|
viewModel: PlayerViewModel,
|
||||||
|
vizStyle: String,
|
||||||
|
playing: Boolean,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier
|
||||||
|
) {
|
||||||
|
val spectrum by viewModel.spectrum.collectAsState()
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
viewModel.setSpectrumActive(true)
|
||||||
|
onDispose { viewModel.setSpectrumActive(false) }
|
||||||
|
}
|
||||||
|
com.radiola.ui.components.Visualizer(
|
||||||
|
style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle),
|
||||||
|
levels = spectrum,
|
||||||
|
playing = playing,
|
||||||
|
color = color,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Чип выбора звука для сна. */
|
/** Чип выбора звука для сна. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SoundChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
private fun SoundChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ class PlayerViewModel @Inject constructor(
|
|||||||
playerController.startSleepTimer(minutes * 60_000L, sound)
|
playerController.startSleepTimer(minutes * 60_000L, sound)
|
||||||
fun cancelSleepTimer() = playerController.cancelSleepTimer()
|
fun cancelSleepTimer() = playerController.cancelSleepTimer()
|
||||||
|
|
||||||
|
// Спектр (FFT) считаем только пока открыт плеер — экономия батареи в фоне.
|
||||||
|
fun setSpectrumActive(active: Boolean) = playerController.setSpectrumActive(active)
|
||||||
|
|
||||||
private var nowPlayingJob: Job? = null
|
private var nowPlayingJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -126,11 +129,16 @@ class PlayerViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { pushHistoryUseCase(station.id) }
|
viewModelScope.launch { pushHistoryUseCase(station.id) }
|
||||||
nowPlayingJob?.cancel()
|
nowPlayingJob?.cancel()
|
||||||
nowPlayingJob = viewModelScope.launch {
|
nowPlayingJob = viewModelScope.launch {
|
||||||
// Polling loop for Record API now playing
|
// Поллинг now-playing — ТОЛЬКО пока играем. collectLatest отменяет
|
||||||
|
// внутренний цикл при паузе (иначе на паузе радио зря дёргали сеть
|
||||||
|
// каждые 5с → батарея + лишняя нагрузка на бэкенд).
|
||||||
launch {
|
launch {
|
||||||
|
playerController.isPlaying.collectLatest { playing ->
|
||||||
|
if (!playing) return@collectLatest
|
||||||
while (true) {
|
while (true) {
|
||||||
nowPlayingRepository.refreshNowPlaying()
|
nowPlayingRepository.refreshNowPlaying()
|
||||||
delay(5_000) // чаще — трек/обложка на плеере обновляются быстрее
|
delay(5_000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Collect now playing for this station (API has priority: covers + accurate metadata)
|
// Collect now playing for this station (API has priority: covers + accurate metadata)
|
||||||
|
|||||||
Reference in New Issue
Block a user