查看原文
其他

Jetpack Compose 编写 AndroidTV 应用

Seiko AndroidPub 2022-07-13

作者:Seiko
https://juejin.cn/post/7018880391515734047

前言

我很好奇 Jetpack Compose 作为一个新的界面工具包,在TV端使用体验会如何,毕竟现有的 leanback 库并不是很好用,而且自定义难度很大,导致大多个人开源的TV项目都长得差不多;

随着正式版的发布,我想在被大浪卷走之前努力一下,学习 Jetpack Compose 并开发一款简单的TV端应用。

项目地址:https://github.com/qdsfdhvh/compose-anime-tv 欢迎点个Star。

预览


1. 副作用(Effect)

Jetpack Compose 两大标签声明式函数式,尤其是函数式是我们主要需要适应的;@Composable 函数会根据UI刷新而重复运行,但是里面的一些如初始化、绑定等行为,或者是一些定义的变量,他们是不可以跟随UI刷新而重新初始化、重复绑定或重新生成的;

为了能让它们在合适的时间运行,就需要使用副作用 Effect 

2. 按键传递(KeyEvent)

为了尽量使用现有的 Modifier 扩展,我首先在官方文档查阅了下 KeyEvent:

Box(
    Modifier
        .onPreviewKeyEvent { keyEvent1 -> false }
        // .onKeyEvent { keyEvent5 -> false }
        .onKeyEvent { keyEvent4 -> false }
) {
    Box(
        Modifier
            .onPreviewKeyEvent { keyEvent2 -> false }
            .onKeyEvent { keyEvent3 -> false }
            .focusable()
    )
}

非常简洁,只有 onKeyEvent()onPreviewKeyEvent() 两个扩展、而且基本能满足开发需要。

3. 焦点处理(Focus)

3.1 Modifier扩展

主要有下面这几个:

  • Modifier.focusTarget()、Modifier.focusable()
  • Modifier.onFocusEvent()、Modifier.onFocusChange()
  • Modifier.focusRequester()、Modifier.focusOrder()

3.1.1 focusable() 与 focusTarget()

focusable() 是对 focusTarget() 的进一步封装,必须配置 focusTarget() 才能获取焦点,正常使用 onFocusChange()onKeyEvent() 等;

官方建议使用 focusable() 而不是直接使用 focusTarget(),但是我这里主要使用的是 focusTarget();

PS:focusTarget() 曾经叫 focusModifier(),我感觉旧名字更能体现为啥一定要配置了才能使用相关方法,所以这里提一下。

3.1.2 onFocusChange() 与 onFocusEvent()

onFocusEvent() 作用是回调焦点状态 FocusState

interface FocusState {
    val isFocused: Boolean
    val hasFocus: Boolean
    val isCaptured: Boolean
}

onFocusChange() 则是对 onFocusEvent() 的封装,只有变化回调 FocusState,类似于 Flow.distinctUntilChanged

一般 onFocusChange() 用的比较多;

3.1.3 focusOrder() 与 focusRequester()

focusRequester() 用于给控件配置 FocusRequester 类:

class FocusRequester {
    fun requestFocus()
    fun captureFocus()Boolean
    fun freeFocus()Boolean
}

FocusRequester.requestFocus() 是给控件获取焦点的唯一手段;

captureFocus()freeFocus() 分别是锁定与释放焦点;

focusOrder() 用于确定下一个获取焦点的控件:

@Composable
fun FocusOrderSample() {
  val (item1, item2, item3, item4) = remember { FocusRequester.createRefs() }
  Box(
    Modifier
      .focusOrder(item1) {
        next = item2
        right = item2
        down = item3
        previous = item4
      }
      .focusable()
  )
  ...
}

官方为了便于 focusOrder() 使用,加了下面这个扩展,为此在项目里我偷懒了下,都使用了 focusOrder() 配置 FocusRequester;

fun Modifier.focusOrder(focusRequester: FocusRequester): Modifier = focusRequester(focusRequester)

简化一下,平时使用较多的 Modifier 扩展就减成了三大件:

  • focusTarget()
  • focusOrder()
  • onFocusChange()

3.2 FocusManager

interface FocusManager {
    fun clearFocus(force: Boolean)
    fun moveFocus(focusDirection: FocusDirection)Boolean
}

通过 LocalFocusManager.current 获取,实现类 FocusManagerImpl 是私有的,同时内部很多变量也是私有的,不便于自定义 FocusManager,能做的事情就比较有限了。

4. 按键 & 焦点传递

进入 AndroidComposeView,从 dispatchKeyEvent() 开始大致预览下实现:

//androidx.compose.ui.platform.AndroidComposeView.android.kt

override fun dispatchKeyEvent(event: AndroidKeyEvent) =
    if (isFocused) {
        sendKeyEvent(KeyEvent(event))
    } else {
        super.dispatchKeyEvent(event)
    }

override fun sendKeyEvent(keyEvent: KeyEvent)Boolean {
    return keyInputModifier.processKeyInput(keyEvent)
}

private val keyInputModifier: KeyInputModifier = KeyInputModifier(
    onKeyEvent = {
        val focusDirection = getFocusDirection(it)
        if (focusDirection == null || it.type != KeyDown) return@KeyInputModifier false

        // Consume the key event if we moved focus.
        focusManager.moveFocus(focusDirection)
    },
    onPreviewKeyEvent = null
)
//androidx.compose.ui.input.key.KeyInputModifier.kt


internal class KeyInputModifier(
    val onKeyEvent: ((KeyEvent) -> Boolean)?,
    val onPreviewKeyEvent: ((KeyEvent) -> Boolean)?
) : Modifier.Element {
    lateinit var keyInputNode: ModifiedKeyInputNode

    fun processKeyInput(keyEvent: KeyEvent)Boolean {
        val activeKeyInputNode = keyInputNode.findPreviousFocusWrapper()
            ?.findActiveFocusNode()
            ?.findLastKeyInputWrapper()
            ?: error("KeyEvent can't be processed because this key input node is not active.")
        return with(activeKeyInputNode) {
            val consumed = propagatePreviewKeyEvent(keyEvent)
            if (consumed) true else propagateKeyEvent(keyEvent)
        }
    }
}

fun Modifier.onPreviewKeyEvent(onPreviewKeyEvent: (KeyEvent) -> Boolean): Modifier = composed {
    KeyInputModifier(onKeyEvent = null, onPreviewKeyEvent = onPreviewKeyEvent)
}

fun Modifier.onKeyEvent(onKeyEvent: (KeyEvent) -> Boolean): Modifier = composed {
    KeyInputModifier(onKeyEvent = onKeyEvent, onPreviewKeyEvent = null)
}

上面的代码结合官方 KeyEvent 的使用示例,可以判断出:

Jetpack Compose 会先把 KeyEvent 交给 Focus 链上配置了 onKeyEvent() 的控件们消费,没有控件消费就会走默认的 onKeyEvent(),约等于 focusManager.moveFocus(focusDirection)

再看下 focusManager 是大致是怎么处理的:

//androidx.compose.ui.focus.FocusManager.kt


class FocusManagerImpl(
    private val focusModifier: FocusModifier = FocusModifier(Inactive)
) : FocusManager {
    ...
    override fun moveFocus(focusDirection: FocusDirection)Boolean {
        val source = focusModifier.focusNode.findActiveFocusNode() ?: return false
        
        val nextFocusRequester = source.customFocusSearch(focusDirection, layoutDirection)
        if (nextFocusRequester != FocusRequester.Default) {
            nextFocusRequester.requestFocus()
            return true
        }

        val destination = focusModifier.focusNode.focusSearch(focusDirection, layoutDirection)
        if (destination == null || destination == source) {
          return false
        }

        // We don't want moveFocus to set focus to the root, as this would essentially clear focus.
        if (destination.findParentFocusNode() == null) {
          return when (focusDirection) {
            // Skip the root and proceed to the next/previous item from the root's perspective.
            Next, Previous -> {
              destination.requestFocus(propagateFocus = false)
              moveFocus(focusDirection)
            }
            // Instead of moving out to the root, we return false.
            // When we return false the key event will not be consumed, but it will bubble
            // up to the owner. (In the case of Android, the back key will be sent to the
            // activity, where it can be handled appropriately).
            @OptIn(ExperimentalComposeUiApi::class)
            Out -> false
            else -> error("Move focus landed at the root through an unknown path.")
          }
        }

        // If we found a potential next item, call requestFocus() to move focus to it.
        destination.requestFocus(propagateFocus = false)
        return true
    }
}

nextFocusRequester 就是通过 focusOrder 配置的下一个目标,如果返回的不是 FocusRequester.Default,就直接 requestFocus()

否则就通过 focusModifier.focusNode.focusSearch() 寻找焦点:

internal fun ModifiedFocusNode.focusSearch(
    focusDirection: FocusDirection,
    layoutDirection: LayoutDirection
)
: ModifiedFocusNode? {
    return when (focusDirection) {
        Next, Previous -> oneDimensionalFocusSearch(focusDirection)
        Left, Right, Up, Down -> twoDimensionalFocusSearch(focusDirection)
        @OptIn(ExperimentalComposeUiApi::class)
        In -> {
            // we search among the children of the active item.
            val direction = when (layoutDirection) { Rtl -> Left; Ltr -> Right }
            findActiveFocusNode()?.twoDimensionalFocusSearch(direction)
        }
        @OptIn(ExperimentalComposeUiApi::class)
        Out -> findActiveFocusNode()?.findParentFocusNode()
        else -> error(invalidFocusDirection)
    }
}

internal fun ModifiedFocusNode.findActiveFocusNode(): ModifiedFocusNode? {
    return when (focusState) {
        Active, Captured -> this
        ActiveParent -> focusedChild?.findActiveFocusNode()
        Inactive, Disabled -> null
    }
}

findActiveFocusNode() 方法主要还是确定当前的焦点,基于当前焦点去寻找下一个目标;oneDimensionalFocusSearch()twoDimensionalFocusSearch() 都是往 child 寻找下一个目标, findParentFocusNode() 则是把焦点传给 parent

做个小总结:

  1. 基于 focusOrder() 确定下一个目标是最直接、最稳定的,不会走后面那些较为复杂的判断,上层方便配置的话尽量配置;
  2. onKeyEvent() 初步看来只适合在 Focus 链的两端使用,不然很可能判断不足,把原本想让 focusManager.moveFocus() 消费的行为给抢走;
  3. 可以通过 focusManager.moveFocus(FocusDirection.Out) 把当前焦点传给 parent。

5. 焦点传递实践

我预期的传递方案大致就是:

  • 每个组件各自处理焦点,焦点从最外层逐步传入;
  • 移动焦点时,当前组件不消费就传给父组件处理。

以示例来说,先自定义两个组件 Box1Box2

@Composable
fun AppScreen() {
  val (focus1, focus2) = remember { FocusRequester.createRefs() }

  Row(
    modifier = Modifier.fillMaxSize(),
    horizontalArrangement = Arrangement.SpaceAround,
    verticalAlignment = Alignment.CenterVertically
  ) {
    Box1(Modifier.focusOrder(focus1) { 
      right = focus2 
      // left = focus2
    })
    Box2(Modifier.focusOrder(focus2) {
      left = focus1
      // right = focus1
    })
  }

  SideEffect {
    focus1.requestFocus()
  }
}

@Composable
fun Box1(modifier: Modifier = Modifier) {
  var isParentFocused by remember { mutableStateOf(false) }
  Box(
    modifier = modifier
      // .background(Color.Green)
      // .size(200.dp)
      .onFocusChanged { isParentFocused = it.isFocused }
      .focusTarget(),
    // contentAlignment = Alignment.Center
  ) {
    Text(
      if (isParentFocused) "Focused" else "",
      // color = Color.White,
      // style = MaterialTheme.typography.h3
    )
  }
}

@Composable
fun Box2(modifier: Modifier = Modifier) {
  ...
}

其他不变的情况下,把 Box1 改成一个List:

@Composable
fun Box1(modifier: Modifier = Modifier) {
  var isParentFocused by remember { mutableStateOf(false) }
  var focusIndex by remember { mutableStateOf(0) }

  LazyColumn(
    modifier = modifier
      .onFocusChanged { isParentFocused = it.isFocused }
      .focusTarget(),
  ) {
    items(10) { index ->
      val focusRequester = remember { FocusRequester() }
      var isFocused by remember { mutableStateOf(false) }
      Text(
        if (isFocused) "Focused" else "",
        // color = Color.Black,
        // style = MaterialTheme.typography.h5,
        // textAlign = TextAlign.Center,
        modifier = Modifier
          // .padding(10.dp)
          // .background(Color.Green)
          // .width(120.dp)
          // .padding(vertical = 10.dp)
          .onFocusChanged {
            isFocused = it.isFocused
            if (isFocused) focusIndex = index
          }
          .focusOrder(focusRequester)
          .focusTarget(),
      )

      if (isParentFocused && focusIndex == index) {
        SideEffect {
          focusRequester.requestFocus()
        }
      }
    }
  }
}

看似没什么问题,但其实向右的跳转并不是根据 AppScreen 中的配置而跳转的,给 Box1 配置 focusOrder(focus1) { left = focus2 },按左键并不能找到 focus2;

这里就需要手动去把焦点传给 parent,借助 onKeyEvent() 在按键传递过程中触发 focusManager.moveFocus(FocusDirection.Out) 把焦点返给 parent,并返回 false 让这个按键继续传递下去;

...
val focusManager = LocalFocusManager.current
LazyColumn(
  modifier = modifier
    // .onFocusChanged { isParentFocused = it.isFocused }
    .onKeyEvent {
      when (it) {
        Key.DirectionRight,
        Key.DirectionLeft -> {
          focusManager.moveFocus(FocusDirection.Out)
        }
      }
      false
    }
    // .focusTarget(),
) {
  ...
}

我在项目中使用的焦点传递方案大致就是这样,目前只能应付一些较为简单的场景,由于有返回焦点给 parent 的行为,单个组件不适合有两层 Focus 的传递,需要把多的一层再拆成组件,不过好在 Jetpack Compose 写一个组件成本很低。

6. 列表滚动

焦点传递方式虽然大致确定了,但是在焦点移动时,列表也是需要跟着滚动的;

通过官方文档很快就找到了相关代码:

val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()

LazyColumn(state = listState) {
    // ...
}

ScrollToTopButton(
    onClick = {
        coroutineScope.launch {
            // Animate scroll to the first item
            listState.animateScrollToItem(index = 0)
        }
    }
)

借助 LazyListState 就能实现列表的滚动,相关方法大概有:

  • listState.scrollBy(value)
  • listState.scrollToItem(index, offset)
  • listState.animateScrollBy(value, animationSpec)
  • listState.animateScrollToItem(index, offset)

单从使用上看 animateScrollToItem() 比较符合需要,给上面的 Box1 添加相关配置,并在focusIndex 变化时触发滚动:

val listState = rememberLazyListState()
...

LazyColumn(
  state = listState
  ...
) {
  ...
}

LaunchedEffect(focusIndex) {
  listState.animateScrollToItem(focusIndex)
}

可以看到 animateScrollToItem() 滚动效果不尽人意,所以我们需要自己去算滚动距离并使用 animateScrollBy() 来滚动;

这方面的实现我参考了 akilarajeshks/SampleComposeApp

interface ScrollBehaviour {
  suspend fun onScroll(state: LazyListState, focusIndex: Int)
}

object VerticalScrollBehaviour : ScrollBehaviour {
  override suspend fun onScroll(state: LazyListState, focusIndex: Int) {
    val focusItem = state.layoutInfo.visibleItemsInfo.find { focusIndex == it.index } ?: return
      
    val viewStart = state.layoutInfo.viewportStartOffset
    val viewEnd = state.layoutInfo.viewportEndOffset
    val viewSize = viewEnd - viewStart

    val itemStart = focusItem.offset
    val itemEnd = focusItem.offset + focusItem.size
    
    // 这里加点距离主要是为了让下一个目标控件绘制出来,不然在visibleItemsInfo会找不到
    val offSect = 80

    val value = when {
      itemStart < viewStart -> itemStart.toFloat() - offSect
      itemEnd > viewStart + viewSize -> (itemEnd - viewSize - viewStart).toFloat() + offSect
      else -> return
    }
    state.animateScrollBy(value, tween(1500, LinearEasing))
  }
}

suspend fun LazyListState.animateScrollToItem(focusIndex: Int, scrollBehaviour: ScrollBehaviour) {
  scrollBehaviour.onScroll(this, focusIndex)
}

再把 Box1 里的滚动代码修改下就完成了:

listState.animateScrollToItem(focusIndex, VerticalScrollBehaviour)

7.播放器

这块我基本都是参照了 halilozercan/ComposeVideoPlayer,它的结构设计的非常好,我只把它里面触摸部分换成了按键的;

大致如下,界面方面最外层一个Box,里面个三个控件分别是:

  • 第一层 画面 MediaPlayerLayout()
  • 第二层 按钮、进度条等小组件 MediaControlLayout()
  • 第三层 监听 KeyEventMediaControlKeyEvent()
@Composable
fun TvVideoPlayer(
  player: Player,
  controller: VideoPlayerController,
  modifier: Modifier = Modifier,
)
 {
  CompositionLocalProvider(
    LocalVideoPlayerController provides controller
  ) {
    Box(modifier = modifier.background(Color.Black)) {
      MediaPlayerLayout(player, modifier = Modifier.matchParentSize())
      MediaControlLayout(modifier = Modifier.matchParentSize())
      MediaControlKeyEvent(modifier = Modifier.matchParentSize())
    }
  }
}

internal val LocalVideoPlayerController =
  compositionLocalOf<VideoPlayerController> { error("VideoPlayerController is not initialized") }

使用 VideoPlayerController 去控制播放和获取当前播放状态:

interface VideoPlayerController {
  val state: StateFlow<VideoPlayerState>
  val isPlaying: Boolean
  fun play()
  fun pause()
  fun playToggle()
  fun reset()
  fun seekTo(positionMs: Long)
  fun seekForward()
  fun seekRewind()
  fun seekFinish()
  fun showControl()
  fun hideControl()
}

7.1 MediaPlayerLayout

播放器使用常规的 Exoplayer,通过 AndroidView 去加载它;

@Composable
fun PlayerSurface(
  modifier: Modifier = Modifier,
  onPlayerViewAvailable: (PlayerView) -> Unit = {}
)
 {
  AndroidView(
    modifier = modifier,
    factory = { context ->
      PlayerView(context).apply {
        useController = false // 关闭默认的控制界面
        onPlayerViewAvailable(this)
      }
    }
  )
}

基于 VideoPlayerController 类,再对 PlayerSurface 做个封装,在 onStartonStoponDestory 做些常规处理:

@Composable
fun MediaPlayerLayout(player: Player, modifier: Modifier = Modifier) {
  val controller = LocalVideoPlayerController.current
  val state by controller.state.collectAsState()

  val lifecycle = LocalLifecycleOwner.current.lifecycle

  PlayerSurface(modifier) { playerView ->
    playerView.player = player

    lifecycle.addObserver(object : LifecycleObserver {
      @OnLifecycleEvent(Lifecycle.Event.ON_START)
      fun onStart() {
        playerView.keepScreenOn = true
        playerView.onResume()
        if (state.isPlaying) {
          controller.play()
        }
      }

      @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
      fun onStop() {
        playerView.keepScreenOn = false
        playerView.onPause()
        controller.pause()
      }
    })
  }
  
  DisposableEffect(Unit) {
    onDispose {
      player.release()
    }
  }
}

7.2 MediaControlLayout

根据当前播放状态,显示 播放/暂停按钮、快进/快退按钮、进度条 等;

@Composable
fun MediaControlLayout(modifier: Modifier = Modifier) {
  val controller = LocalVideoPlayerController.current
  val state by controller.state.collectAsState()

  val isSeeking by remember(state.seekDirection) {
    mutableStateOf(state.seekDirection.isSeeking)
  }

  if (!state.controlsVisible && !isSeeking) {
    return
  }

  val position = remember(state.currentPosition) { getDurationString(state.currentPosition) }
  val duration = remember(state.duration) { getDurationString(state.duration) }

  Box(modifier = modifier) {

    Column(
      modifier = Modifier
        .fillMaxWidth()
        .align(Alignment.BottomCenter)
        .padding(4.dp)
    ) {
      TimeTextBar(
        modifier = Modifier
          .fillMaxWidth()
          .padding(bottom = 4.dp),
        position = position,
        duration = duration
      )
      SmallSeekBar(
        modifier = Modifier
          .fillMaxWidth(),
        secondaryProgress = state.bufferedPosition,
        progress = state.currentPosition,
        max = state.duration,
      )
    }

    if (!isSeeking) {
      PlayToggleButton(
        modifier = Modifier.align(Alignment.Center),
        isPlaying = state.isPlaying,
        playbackState = state.playbackState
      )
    }
  }
}

7.3 MediaControlKeyEvent

定义个空白的 Box 并监听 onKeyEvent,这里就不用考虑传给 FocusManager 了,直接消费掉按键;

@Composable
fun MediaControlKeyEvent(modifier: Modifier = Modifier) {
  val controller = LocalVideoPlayerController.current
  val state by controller.state.collectAsState()

  val focusRequester = remember { FocusRequester() }

  Box(
    modifier = modifier
      .onFocusDirection {
        when (it) {
          FocusDirection.In -> {
            if (state.isPlaying) {
              controller.pause()
              controller.showControl()
            } else {
              controller.play()
              controller.hideControl()
            }
            true
          }
          FocusDirection.Down -> {
            if (state.controlsVisible) {
              controller.hideControl()
            } else {
              controller.showControl()
            }
            true
          }
          FocusDirection.Left -> {
            controller.seekRewind()
            true
          }
          FocusDirection.Right -> {
            controller.seekForward()
            true
          }
          FocusDirection.Out -> {
            if (state.controlsVisible) {
              controller.hideControl()
              true
            } else false
          }
          else -> false
        }
      }
      .focusRequester(focusRequester)
      .focusTarget(),
  ) {
    VideoSeekAnimation(
      modifier = Modifier.matchParentSize(),
      seekDirection = state.seekDirection,
    )
  }

  SideEffect {
    focusRequester.requestFocus()
  }
}

8. Jetpack Compose 中使用 ViewModel

8.1 一般 ViewModel

// implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0-beta01")
val viewModel: FeedViewModel = viewModel()

8.2 Hilt Inject ViewModel

目前官方好像只提供了基于 navigation 的实现版本:

https://github.com/google/dagger/issues/2166

// implementation("androidx.hilt:hilt-navigation-compose:1.0.0-alpha03")
val viewModel: FeedViewModel = hiltViewModel()

8.3 Hilt AssistedInject ViewModel

逛 Github 的时候看到有大佬在 Jetpack Compose中 使用了这种方式,我觉得还是很不错的,对于函数式的 Jetpack Compose 来说,在创建 ViewModel 的时候传入参数是比较合适的;

class DetailViewModel @AssistedInject constructor(
  @Assisted id: Long,
  ...
) : ViewModel() {
  ...

  @dagger.assisted.AssistedFactory
  interface AssistedFactory {
    fun create(id: Long): DetailViewModel
  }
}

缺点是用 AssistedInject 注入要写的代码会多一些,有时候使用像 produceState 这种方式会更简单,具体就看情况使用;

@Composable
fun DetailScreen(id: Long) {
    val viewState by produceState(initialValue = DetailViewState.Empty) {
      viewModel.loadState(id).collect {
        value = it
      }
    }
    ...
}

如何注入可参考 https://gist.github.com/qdsfdhvh/0e5b1d04851354af1e6cda5b96582bb8

参考里面可以做一些下面的调整:

  1. @IntoMap 改成 @IntoSet 就可以不用配置 @AssistedFactoryKey ;

  2. AssistedFactoryModule.kt 可以使用 KSP 去生成,项目中通过 FAssistedFactoryProcessor 生成代码,大致如下:

@InstallIn(SingletonComponent::class)
@Module
public interface DetailViewModelFactoryModule {
  @Binds
  @IntoMap
  @AssistedFactoryQualifier
  @AssistedFactoryKey(DetailViewModel.AssistedFactory::class)
  public fun bindDetailViewModelFactory(factory: DetailViewModel.AssistedFactory): Any
}

我跑了下 --dry-run 发现好像 kapt task 有依赖 ksp task,这样用 ksp 生成 hilt module 在 task 执行顺序上应该没问题,目前试下来也没遇到什么问题。

./gradlew app:kaptDebugKotlin --dry-run

// ....
// :app:kspDebugKotlin SKIPPED
// :app:kaptGenerateStubsDebugKotlin SKIPPED
// :app:kaptDebugKotlin SKIPPED

9. 其他

9.1  Jetpack Compose 制作图标

前段时间抄fundroid大佬的俄罗斯方块代码时,发现了一个很有趣的小技巧:编写一个 @Composable fun AppIcon() {...},通过预览功能右击 "copy image"保存图片,就可以简单制作一个App图标;对于像我这样不会ps的来说还是挺有用的。

9.2 查看 Icons

在使用Icons图标的时候,因为看不到预览挺麻烦的,在官方上找到了这个网站 https://fonts.google.com/icons,目前我是在这里搜索和预览的,不知道有没有更好的方式。

9.3 屏幕适配

在 Jetpack Compose 中提供了 .dp.sp 扩展,换算则是借助了 Density 这个类,在 Android 中这个类是这样创建的:

fun Density(context: Context): Density =
  Density(
    context.resources.displayMetrics.density,
    context.resources.configuration.fontScale
  )

可以使使用 AndroidAutoSize 这类库达到效果,但是为了项目能更 Compose 些,我自定义了下 Density:

fun autoSizeDensity(context: Context, designWidthInDp: Int): Density =
  with(context.resources) {
    val isVertical = configuration.orientation == Configuration.ORIENTATION_PORTRAIT

    val scale = displayMetrics.run {
      val sizeInDp = if (isVertical) widthPixels else heightPixels
      sizeInDp.toFloat() / density / designWidthInDp
    }

    Density(
      density = displayMetrics.density * scale,
      fontScale = configuration.fontScale * scale
    )
  }
  
// 使用
setContent {
  ...
  CompositionLocalProvider(
    LocalDensity provides autoSizeDensity(this@AnimeTvActivity480)
  ) {
    ...
  }
}

PS: 上面的方法只能适配 Compose,不支持 AndroidView。

9.4 取消点击波纹

Jetpack Compose 在点击时默认有波纹的,对 TV 来说并不需要, 所以项目自定义了 LocalIndication, 并在 MaterialTheme 进行配置:

object NoRippleIndication : Indication {
  private object NoIndicationInstance : IndicationInstance {
    override fun ContentDrawScope.drawIndication() {
      drawContent()
    }
  }

  @Composable
  override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
    return NoIndicationInstance
  }
}

// 使用
setContent {
  ...
  MaterialTheme {
    CompositionLocalProvider(
      LocalIndication provides NoRippleIndication
    ) {
      ...
    }
  }
}

9.5  @CollectCompose 注入小组件

我尝试在界面上加载一些小组件,如 fps 等;一开始我是放在app里的,后面就想着把它放入其他 module 里面通过注入的方式去加载它,主要想研究下这方面的可行性;

一开始我想着使用 ASM 去收集这些小组件的 @Composable 函数,好在巨佬给了建议,ASM入局太晚,彼时的 Compose 代码是比较复杂的,要实现并不容易, 对于还没写过ASM的我来说这条路≈不可能,及时止损没有误入歧途(怂了);

之后我还是用了老方法:使用 ksp 生成hilt代码来注入 Composable组件;

@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FUNCTION)
annotation class CollectCompose(
  val qualifier: KClass<out Any>
)

interface CollectComposeOwner<in T> {
  @Composable
  fun Show(scope: T)
}

@Composable
fun <T> T.Show(owners: Collection<CollectComposeOwner<T>>) {
  owners.forEach { owner -> owner.Show(this) }
}

@Composable 是 kcp 处理的,而 kapt 晚于 kcp,所以对 hilt 来说,@Composable (BoxScope) -> Unit 已经通过编译变成 Function3<BoxScope, Composer, Int, Unit>,不便于收集了,因此需要借助 CollectComposeOwner 接口:

CollectComposeProcessor.kt ksp 对 @CollectCompose 进行处理,生成的 hilt 代码大致如下:


@InstallIn(ActivityComponent::class)
@Module
object FpsScreenComponentModule {
  @Provides
  @IntoSet
  @CollectScreenComponentQualifier
  fun provideFpsScreenComponent() = object : CollectComposeOwner<BoxScope> {
    @Composable
    override fun Show(scope: BoxScope) {
      scope.FpsScreenComponent()
    }
  }
}

使用处:

@CollectCompose(CollectScreenComponentQualifier::class)
@Composable
fun BoxScope.FpsScreenComponent() {
  ...
}
@AndroidEntryPoint
class AnimeTvActivity : ComponentActivity() {

  @Inject
  @CollectScreenComponentQualifier
  lateinit var collectScreenComponents: Set<@JvmSuppressWildcards CollectComposeOwner<BoxScope>>
    
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      Box() {
        AppScreen()
        Show(collectScreenComponents)
      }
    }
  }
}

除了在界面显示 fps,我也尝试以此实现 Compose Toast(只是尝试,不建议这么用):

object ToastUtils {
  fun showToast(msg: String?) {
    if (msg == nullreturn
    channel.trySend(msg)
  }
}

private val channel = Channel<String>(1)

@CollectCompose(CollectScreenComponentQualifier::class)
@Composable
fun BoxScope.ToastScreenComponent() {

  var isShown by remember { mutableStateOf(false) }
  var showMsg by remember { mutableStateOf("") }

  LaunchedEffect(Unit) {
    channel.receiveAsFlow().collect {
      showMsg = it
      isShown = true
    }
  }

  AnimatedVisibility(
    visible = isShown,
    modifier = Modifier
      .padding(10.dp)
      .padding(bottom = 50.dp)
      .align(Alignment.BottomCenter),
    enter = fadeIn(),
    exit = fadeOut()
  ) {
    Text(
      text = showMsg,
      modifier = Modifier
        .shadow(1.dp, CircleShape)
        .background(MaterialTheme.colors.surface, CircleShape)
        .padding(horizontal = 20.dp, vertical = 10.dp)
    )
  }

  if (isShown) {
    LaunchedEffect(isShown) {
      delay(1500)
      isShown = false
    }
  }
}

右上角加了一个按钮是想试这个square/radiography,很不错的一个库,输出当前界面的Tree,支持Compose,效果如下:




-- End --


欢迎关注 Jetpack Compose 中文手册项目

https://github.com/compose-museum/compose-library


推荐阅读


加好友进交流群,技术干货聊不停



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存