mirror of
https://github.com/androidx/media.git
synced 2025-04-30 06:46:50 +08:00
Add util to handle background state updates and placeholder states.
This is a common pattern in media3 libraries where tasks are handled on a background thread, but the calling thread sees an immediate state update with a best-guess placeholder. This makes the integration for the caller very easy as the API surface appears to be synchronous. This util is a helper class to handle this logic and test it separately. PiperOrigin-RevId: 716233966
This commit is contained in:
parent
fda8b8a35d
commit
297b2b9956
@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2025 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.common.util;
|
||||
|
||||
import static androidx.media3.common.util.Assertions.checkState;
|
||||
|
||||
import android.os.Looper;
|
||||
import androidx.annotation.Nullable;
|
||||
import com.google.common.base.Function;
|
||||
import org.checkerframework.checker.nullness.qual.NonNull;
|
||||
|
||||
/**
|
||||
* Helper class to handle state updates on a background thread while maintaining a placeholder state
|
||||
* on the foreground thread.
|
||||
*
|
||||
* @param <T> An immutable object representing the entire state. Must implement {@link
|
||||
* Object#equals(Object)}.
|
||||
*/
|
||||
@UnstableApi
|
||||
public final class BackgroundThreadStateHandler<T extends @NonNull Object> {
|
||||
|
||||
/**
|
||||
* An interface to handle changes to the state on the foreground thread.
|
||||
*
|
||||
* @param <T> An immutable object representing the entire state. Must implement {@link
|
||||
* Object#equals(Object)}.
|
||||
*/
|
||||
public interface StateChangeListener<T extends @NonNull Object> {
|
||||
|
||||
/**
|
||||
* The state has changed.
|
||||
*
|
||||
* <p>A typical usage of this method is to inform external listeners.
|
||||
*
|
||||
* <p>This method will be called on the foreground thread.
|
||||
*
|
||||
* @param oldState The old state.
|
||||
* @param newState The new state.
|
||||
*/
|
||||
void onStateChanged(T oldState, T newState);
|
||||
}
|
||||
|
||||
private final HandlerWrapper backgroundHandler;
|
||||
private final HandlerWrapper foregroundHandler;
|
||||
private final StateChangeListener<T> onStateChanged;
|
||||
|
||||
private T foregroundState;
|
||||
private T backgroundState;
|
||||
private int pendingOperations;
|
||||
|
||||
/**
|
||||
* Creates the helper for background thread state updates.
|
||||
*
|
||||
* <p>This constructor may be called on any thread.
|
||||
*
|
||||
* @param initialState The initial state value.
|
||||
* @param backgroundLooper The {@link Looper} to run background operations on.
|
||||
* @param foregroundLooper The {@link Looper} to run foreground operations on.
|
||||
* @param clock The {@link Clock} to control the handler messages.
|
||||
* @param onStateChanged The {@link StateChangeListener} to listen to state changes.
|
||||
*/
|
||||
public BackgroundThreadStateHandler(
|
||||
T initialState,
|
||||
Looper backgroundLooper,
|
||||
Looper foregroundLooper,
|
||||
Clock clock,
|
||||
StateChangeListener<T> onStateChanged) {
|
||||
backgroundHandler = clock.createHandler(backgroundLooper, /* callback= */ null);
|
||||
foregroundHandler = clock.createHandler(foregroundLooper, /* callback= */ null);
|
||||
foregroundState = initialState;
|
||||
backgroundState = initialState;
|
||||
this.onStateChanged = onStateChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current state.
|
||||
*
|
||||
* <p>Can be called on either the foreground or background thread, returning the respective
|
||||
* current state of this thread.
|
||||
*/
|
||||
public T get() {
|
||||
@Nullable Looper myLooper = Looper.myLooper();
|
||||
if (myLooper == foregroundHandler.getLooper()) {
|
||||
return foregroundState;
|
||||
}
|
||||
checkState(myLooper == backgroundHandler.getLooper());
|
||||
return backgroundState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an asynchronous state update.
|
||||
*
|
||||
* <p>Must only be called on the foreground thread.
|
||||
*
|
||||
* @param placeholderState A function to create a placeholder state from the current state while
|
||||
* the operation is pending. Will be called on the foreground thread.
|
||||
* @param backgroundStateUpdate A function to handle the background state update, taking in the
|
||||
* current background state and returning the updated state. Will be called on the background
|
||||
* thread.
|
||||
*/
|
||||
public void updateStateAsync(
|
||||
Function<T, T> placeholderState, Function<T, T> backgroundStateUpdate) {
|
||||
checkState(Looper.myLooper() == foregroundHandler.getLooper());
|
||||
pendingOperations++;
|
||||
backgroundHandler.post(
|
||||
() -> {
|
||||
backgroundState = backgroundStateUpdate.apply(backgroundState);
|
||||
T newState = backgroundState;
|
||||
foregroundHandler.post(
|
||||
() -> {
|
||||
if (--pendingOperations == 0) {
|
||||
updateStateInForeground(newState);
|
||||
}
|
||||
});
|
||||
});
|
||||
updateStateInForeground(placeholderState.apply(foregroundState));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the background state directly, independent to any operation started from the foreground
|
||||
* thread.
|
||||
*
|
||||
* <p>Must only be called on the background thread.
|
||||
*
|
||||
* @param newState The new state.
|
||||
*/
|
||||
public void setStateInBackground(T newState) {
|
||||
backgroundState = newState;
|
||||
foregroundHandler.post(
|
||||
() -> {
|
||||
if (pendingOperations == 0) {
|
||||
updateStateInForeground(newState);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the provided {@link Runnable} on the background thread.
|
||||
*
|
||||
* <p>Can be called from any thread.
|
||||
*
|
||||
* <p>Note: This method is useful to update the state on the background using {@link
|
||||
* #setStateInBackground} for events arriving from external sources. Use {@link #updateStateAsync}
|
||||
* if the intention is update the state in response the a foreground thread method call.
|
||||
*
|
||||
* @param runnable The {@link Runnable} to be called on the background thread.
|
||||
*/
|
||||
public void runInBackground(Runnable runnable) {
|
||||
backgroundHandler.post(runnable);
|
||||
}
|
||||
|
||||
private void updateStateInForeground(T newState) {
|
||||
T oldState = foregroundState;
|
||||
foregroundState = newState;
|
||||
if (!oldState.equals(newState)) {
|
||||
onStateChanged.onStateChanged(oldState, newState);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,346 @@
|
||||
/*
|
||||
* Copyright 2025 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.common.util;
|
||||
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.robolectric.Shadows.shadowOf;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.os.Looper;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.util.BackgroundThreadStateHandler.StateChangeListener;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.shadows.ShadowLooper;
|
||||
|
||||
/** Unit test for {@link BackgroundThreadStateHandler}. */
|
||||
@SuppressWarnings("unchecked") // Mocks of listeners are all unchecked.
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class BackgroundThreadStateHandlerTest {
|
||||
|
||||
private HandlerThread backgroundThread;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
backgroundThread = new HandlerThread("BackgroundThreadStateHandlerTest");
|
||||
backgroundThread.start();
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
backgroundThread.quit();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void get_afterConstructor_returnsInitialState() throws Exception {
|
||||
TestState initialState = new TestState(2);
|
||||
// Create handler from another thread to test promise it can be created on any thread.
|
||||
AtomicReference<BackgroundThreadStateHandler<TestState>> handler = new AtomicReference<>();
|
||||
StateChangeListener<TestState> mockListener = mock(StateChangeListener.class);
|
||||
Thread testThread =
|
||||
new Thread("otherThread") {
|
||||
@Override
|
||||
public void run() {
|
||||
handler.set(
|
||||
new BackgroundThreadStateHandler<>(
|
||||
initialState,
|
||||
backgroundThread.getLooper(),
|
||||
Looper.getMainLooper(),
|
||||
Clock.DEFAULT,
|
||||
mockListener));
|
||||
}
|
||||
};
|
||||
testThread.start();
|
||||
testThread.join();
|
||||
|
||||
TestState foregroundState = handler.get().get();
|
||||
AtomicReference<TestState> backgroundState = new AtomicReference<>();
|
||||
CountDownLatch waitForBackgroundState = new CountDownLatch(1);
|
||||
new Handler(backgroundThread.getLooper())
|
||||
.post(
|
||||
() -> {
|
||||
backgroundState.set(handler.get().get());
|
||||
waitForBackgroundState.countDown();
|
||||
});
|
||||
waitForBackgroundState.await();
|
||||
|
||||
assertThat(foregroundState).isEqualTo(initialState);
|
||||
assertThat(backgroundState.get()).isEqualTo(initialState);
|
||||
verifyNoMoreInteractions(mockListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void get_immediatelyAfterUpdateStateAsync_returnsPlaceholderStateAndInformsListener() {
|
||||
TestState initialState = new TestState(2);
|
||||
TestState placeholderState = new TestState(3);
|
||||
TestState finalState = new TestState(4);
|
||||
StateChangeListener<TestState> mockListener = mock(StateChangeListener.class);
|
||||
BackgroundThreadStateHandler<TestState> handler =
|
||||
new BackgroundThreadStateHandler<>(
|
||||
initialState,
|
||||
backgroundThread.getLooper(),
|
||||
Looper.getMainLooper(),
|
||||
Clock.DEFAULT,
|
||||
mockListener);
|
||||
|
||||
AtomicReference<TestState> placeholderArgument = new AtomicReference<>();
|
||||
handler.updateStateAsync(
|
||||
/* placeholderState= */ state -> {
|
||||
placeholderArgument.set(state);
|
||||
return placeholderState;
|
||||
},
|
||||
/* backgroundStateUpdate= */ state -> finalState);
|
||||
TestState stateAfterUpdate = handler.get();
|
||||
|
||||
assertThat(stateAfterUpdate).isEqualTo(placeholderState);
|
||||
assertThat(placeholderArgument.get()).isEqualTo(initialState);
|
||||
verify(mockListener).onStateChanged(initialState, placeholderState);
|
||||
verifyNoMoreInteractions(mockListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void get_afterUpdateStateAsyncWithIdenticalFinalValue_onlyCallsListenerOnce() {
|
||||
TestState initialState = new TestState(2);
|
||||
TestState updatedState = new TestState(3);
|
||||
StateChangeListener<TestState> mockListener = mock(StateChangeListener.class);
|
||||
BackgroundThreadStateHandler<TestState> handler =
|
||||
new BackgroundThreadStateHandler<>(
|
||||
initialState,
|
||||
backgroundThread.getLooper(),
|
||||
Looper.getMainLooper(),
|
||||
Clock.DEFAULT,
|
||||
mockListener);
|
||||
|
||||
handler.updateStateAsync(
|
||||
/* placeholderState= */ state -> updatedState,
|
||||
/* backgroundStateUpdate= */ state -> updatedState);
|
||||
waitForPendingTasks();
|
||||
TestState stateAfterUpdate = handler.get();
|
||||
|
||||
assertThat(stateAfterUpdate).isEqualTo(updatedState);
|
||||
verify(mockListener).onStateChanged(initialState, updatedState);
|
||||
verifyNoMoreInteractions(mockListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void get_afterUpdateStateAsyncWithDifferentFinalValue_callsListenerAgain() {
|
||||
TestState initialState = new TestState(2);
|
||||
TestState placeholderState = new TestState(3);
|
||||
TestState finalState = new TestState(4);
|
||||
StateChangeListener<TestState> mockListener = mock(StateChangeListener.class);
|
||||
BackgroundThreadStateHandler<TestState> handler =
|
||||
new BackgroundThreadStateHandler<>(
|
||||
initialState,
|
||||
backgroundThread.getLooper(),
|
||||
Looper.getMainLooper(),
|
||||
Clock.DEFAULT,
|
||||
mockListener);
|
||||
|
||||
AtomicReference<TestState> backgroundUpdateArgument = new AtomicReference<>();
|
||||
handler.updateStateAsync(
|
||||
/* placeholderState= */ state -> placeholderState,
|
||||
/* backgroundStateUpdate= */ state -> {
|
||||
backgroundUpdateArgument.set(state);
|
||||
return finalState;
|
||||
});
|
||||
waitForPendingTasks();
|
||||
TestState stateAfterUpdate = handler.get();
|
||||
|
||||
assertThat(stateAfterUpdate).isEqualTo(finalState);
|
||||
verify(mockListener).onStateChanged(initialState, placeholderState);
|
||||
verify(mockListener).onStateChanged(placeholderState, finalState);
|
||||
verifyNoMoreInteractions(mockListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void get_afterMultipleOverlappingUpdateStateAsync_onlyReportsFinalState() {
|
||||
TestState initialState = new TestState(2);
|
||||
TestState placeholderState1 = new TestState(3);
|
||||
TestState finalState1 = new TestState(4);
|
||||
TestState placeholderState2 = new TestState(5);
|
||||
TestState finalState2 = new TestState(6);
|
||||
StateChangeListener<TestState> mockListener = mock(StateChangeListener.class);
|
||||
BackgroundThreadStateHandler<TestState> handler =
|
||||
new BackgroundThreadStateHandler<>(
|
||||
initialState,
|
||||
backgroundThread.getLooper(),
|
||||
Looper.getMainLooper(),
|
||||
Clock.DEFAULT,
|
||||
mockListener);
|
||||
|
||||
AtomicReference<TestState> placeholderArgument1 = new AtomicReference<>();
|
||||
AtomicReference<TestState> placeholderArgument2 = new AtomicReference<>();
|
||||
AtomicReference<TestState> backgroundUpdateArgument1 = new AtomicReference<>();
|
||||
AtomicReference<TestState> backgroundUpdateArgument2 = new AtomicReference<>();
|
||||
handler.updateStateAsync(
|
||||
/* placeholderState= */ state -> {
|
||||
placeholderArgument1.set(state);
|
||||
return placeholderState1;
|
||||
},
|
||||
/* backgroundStateUpdate= */ state -> {
|
||||
backgroundUpdateArgument1.set(state);
|
||||
return finalState1;
|
||||
});
|
||||
handler.updateStateAsync(
|
||||
/* placeholderState= */ state -> {
|
||||
placeholderArgument2.set(state);
|
||||
return placeholderState2;
|
||||
},
|
||||
/* backgroundStateUpdate= */ state -> {
|
||||
backgroundUpdateArgument2.set(state);
|
||||
return finalState2;
|
||||
});
|
||||
TestState stateImmediatelyAfterUpdate = handler.get();
|
||||
waitForPendingTasks();
|
||||
TestState stateAfterFinalUpdate = handler.get();
|
||||
|
||||
assertThat(stateImmediatelyAfterUpdate).isEqualTo(placeholderState2);
|
||||
assertThat(stateAfterFinalUpdate).isEqualTo(finalState2);
|
||||
assertThat(placeholderArgument1.get()).isEqualTo(initialState);
|
||||
assertThat(placeholderArgument2.get()).isEqualTo(placeholderState1);
|
||||
assertThat(backgroundUpdateArgument1.get()).isEqualTo(initialState);
|
||||
assertThat(backgroundUpdateArgument2.get()).isEqualTo(finalState1);
|
||||
verify(mockListener).onStateChanged(initialState, placeholderState1);
|
||||
verify(mockListener).onStateChanged(placeholderState1, placeholderState2);
|
||||
verify(mockListener).onStateChanged(placeholderState2, finalState2);
|
||||
verifyNoMoreInteractions(mockListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void runInBackground_executesInBackground() throws Exception {
|
||||
BackgroundThreadStateHandler<TestState> handler =
|
||||
new BackgroundThreadStateHandler<>(
|
||||
new TestState(2),
|
||||
backgroundThread.getLooper(),
|
||||
Looper.getMainLooper(),
|
||||
Clock.DEFAULT,
|
||||
(oldState, newState) -> {});
|
||||
|
||||
// Test that calling this from another third thread is possible.
|
||||
AtomicReference<Looper> taskLooper = new AtomicReference<>();
|
||||
Thread testThread =
|
||||
new Thread("otherThread") {
|
||||
@Override
|
||||
public void run() {
|
||||
handler.runInBackground(() -> taskLooper.set(Looper.myLooper()));
|
||||
}
|
||||
};
|
||||
testThread.start();
|
||||
testThread.join();
|
||||
waitForPendingTasks();
|
||||
|
||||
assertThat(taskLooper.get()).isEqualTo(backgroundThread.getLooper());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void setStateInBackground_updatesStateAndCallsListener() {
|
||||
TestState initialState = new TestState(2);
|
||||
TestState updatedState = new TestState(3);
|
||||
StateChangeListener<TestState> mockListener = mock(StateChangeListener.class);
|
||||
BackgroundThreadStateHandler<TestState> handler =
|
||||
new BackgroundThreadStateHandler<>(
|
||||
initialState,
|
||||
backgroundThread.getLooper(),
|
||||
Looper.getMainLooper(),
|
||||
Clock.DEFAULT,
|
||||
mockListener);
|
||||
|
||||
handler.runInBackground(() -> handler.setStateInBackground(updatedState));
|
||||
waitForPendingTasks();
|
||||
TestState stateAfterUpdate = handler.get();
|
||||
|
||||
assertThat(stateAfterUpdate).isEqualTo(updatedState);
|
||||
verify(mockListener).onStateChanged(initialState, updatedState);
|
||||
verifyNoMoreInteractions(mockListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void setStateInBackground_whileAsyncUpdateInProgress_onlyUpdatesFinalState()
|
||||
throws Exception {
|
||||
TestState initialState = new TestState(2);
|
||||
TestState updatedBackgroundState = new TestState(3);
|
||||
TestState finalState = new TestState(4);
|
||||
StateChangeListener<TestState> mockListener = mock(StateChangeListener.class);
|
||||
BackgroundThreadStateHandler<TestState> handler =
|
||||
new BackgroundThreadStateHandler<>(
|
||||
initialState,
|
||||
backgroundThread.getLooper(),
|
||||
Looper.getMainLooper(),
|
||||
Clock.DEFAULT,
|
||||
mockListener);
|
||||
|
||||
handler.runInBackground(() -> handler.setStateInBackground(updatedBackgroundState));
|
||||
AtomicReference<TestState> backgroundUpdateArgument = new AtomicReference<>();
|
||||
handler.updateStateAsync(
|
||||
/* placeholderState= */ state -> state,
|
||||
/* backgroundStateUpdate= */ state -> {
|
||||
backgroundUpdateArgument.set(state);
|
||||
return finalState;
|
||||
});
|
||||
waitForPendingTasks();
|
||||
TestState stateAfterUpdate = handler.get();
|
||||
|
||||
assertThat(stateAfterUpdate).isEqualTo(finalState);
|
||||
assertThat(backgroundUpdateArgument.get()).isEqualTo(updatedBackgroundState);
|
||||
verify(mockListener).onStateChanged(initialState, finalState);
|
||||
verifyNoMoreInteractions(mockListener);
|
||||
}
|
||||
|
||||
private void waitForPendingTasks() {
|
||||
shadowOf(backgroundThread.getLooper()).idle();
|
||||
ShadowLooper.idleMainLooper();
|
||||
}
|
||||
|
||||
public static final class TestState {
|
||||
|
||||
public final int value;
|
||||
|
||||
public TestState(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof TestState)) {
|
||||
return false;
|
||||
}
|
||||
TestState testState = (TestState) o;
|
||||
return value == testState.value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return Integer.toString(value);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user