乐趣区

关于android:Android进阶用Jetpack-Compose编写一款简单的AndroidTV应用

前言

我很好奇 Jetpack Compose 作为一个新的界面工具包,在 TV 端应用体验会如何,毕竟现有的 leanback 库并不是很好用,而且自定义难度很大,导致大多集体开源的 TV 我的项目都长得差不多;
随着正式版的公布,我想在被大浪卷走之前致力一下,学习 Jetpack Compose 并开发一款简略的 TV 端利用;
同时听取巨佬的倡议 -养成写文章的习惯对技能会有所晋升,尝试写下这篇文章做些经验总结。

预览

我的项目地址:compose-anime-tv
喜爱的话,欢送点个 Star。


1. 副作用(Effect)

放在第一个说次要是我感觉副作用对 Jetpack Compose 真的很重要,不须要很理解,但肯定要晓得这是啥;
Jetpack Compose 两大标签 申明式 函数式 ,尤其是 函数式 是咱们次要须要适应的;@Composable函数会依据 UI 刷新而反复运行,然而外面的一些如初始化、绑定等行为,或者是一些定义的变量,他们是不能够追随 UI 刷新而从新初始化、反复绑定或从新生成的;
为了能让它们在适合的工夫运行,就须要应用副作用 Effect
这里举荐下 fundroid 大佬的这篇文章,写得十分好,连(副作用)的命名都有解释;
Jetpack Compose Side Effect:如何解决副作用 @fundroid

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() 两个扩大、而且根本能满足开发须要。

  1. 焦点解决 (Focus)
    官网 sample:androidx.compose.ui.samples.FocusableSample

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()
kotlin.UninitializedPropertyAccessException: lateinit property relocationRequesterNode has not been initialized

    at androidx.compose.ui.layout.RelocationRequesterModifier.getRelocationRequesterNode(RelocationRequesterModifier.kt:32)
    at androidx.compose.ui.layout.RelocationRequester.bringIntoView(RelocationRequester.kt:61)
    at androidx.compose.ui.layout.RelocationRequester.bringIntoView$default(RelocationRequester.kt:59)
    at androidx.compose.foundation.FocusableKt$focusable$2$4$1.invokeSuspend(Focusable.kt:108)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
    ...

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. Jetpack Compose 中的按键 & 焦点传递

进入 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
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,这个会是我比拟罕用的,我查了下这个办法的援用,目前如同只能通过 focusManager.moveFocus(FocusDirection.Out)去触发;
做个小总结:

基于 focusOrder()确定下一个指标是最间接、最稳固的,不会走前面那些较为简单的判断,下层不便配置的话尽量配置;

尽管我很喜爱 onKeyEvent(),然而 onKeyEvent()初步看来只适宜在 Focus 链的两端应用,不然很可能判断有余,把本来想让 focusManager.moveFocus()生产的行为给抢走;

能够通过 focusManager.moveFocus(FocusDirection.Out)把以后焦点传给 parent。

  1. 焦点传递实际
    我预期的传递计划大抵就是:

每个组件各自解决焦点,焦点从最外层逐渐传入;挪动焦点时,以后组件不生产就传给父组件解决。

以示例来说,先自定义两个组件 Box1 与 Box2:
@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 写一个组件老本很低。

  1. 列表滚动
    焦点传递形式尽管大抵确定了,然而在焦点挪动时,列表也是须要跟着滚动的;
    通过官网文档,很快就找到了相干代码:
    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()来滚动;
这方面的实现我根本就抄了 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(150, 0, LinearEasing))

}
}

suspend fun LazyListState.animateScrollToItem(focusIndex: Int, scrollBehaviour: ScrollBehaviour) {
scrollBehaviour.onScroll(this, focusIndex)
}
复制代码
再把 Box1 里的滚动代码批改下就实现了:
listState.animateScrollToItem(focusIndex, VerticalScrollBehaviour)
复制代码

  1. 播放器
    这块我根本都是参照了 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()
}
复制代码
8.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 做个封装,在 onStart、onStop、onDestory 做些惯例解决:
@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()
}

}
}
复制代码
8.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
  )
}

}
}
复制代码
8.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()

}
}
复制代码

  1. Jetpack Compose 中应用 ViewModel
    9.1 个别 ViewModel
    // implementation(“androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0-beta01”)
    val viewModel: FeedViewModel = viewModel()
    复制代码
    9.2 Hilt Inject ViewModel
    目前官网如同只提供了基于 navigation 的实现版本:

    Dagger/Hilt ViewModel Injection (with compose and navigation-compose)

    // implementation(“androidx.hilt:hilt-navigation-compose:1.0.0-alpha03”)
    val viewModel: FeedViewModel = hiltViewModel()
    复制代码
    9.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

    }
    }

    }
    复制代码
    如何注入可参考:AssistedInject viewModel with Jetpack Compose
    参考外面能够做一些上面的调整:

把 @IntoMap 改成 @IntoSet 就能够不必配置 @AssistedFactoryKey;

AssistedFactoryModule.kt 能够应用 ksp 去生成,我是这么写的 AssistedFactoryProcessor,生成的 hilt 代码大抵如下:
@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
复制代码
也能够应用 Tlaster 大佬在 TwidereProject 中的计划收集 AssistedFactory。
其余

  1. 应用 Jetpack Compose 制作图标
    前段时间抄 fundroid 大佬的俄罗斯方块代码时,发现了一个很乏味的小技巧:

编写一个 @Composable fun AppIcon() {…},通过预览性能右击 ”copy image” 保留图片,就能够简略制作一个 App 图标;对于像我这样不会 ps 的来说还是挺有用的。

  1. 查看 Icons
    在应用 Icons 图标的时候,因为看不到预览挺麻烦的,在官网上找到了这个网站 Google Fonts,目前我是在这里搜寻和预览的,不晓得有没有更好的形式。
  2. 屏幕适配
    在 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@AnimeTvActivity, 480)

) {

...

}
}
复制代码
PS: 下面的办法只能适配 Compose,不反对 AndroidView。

  1. 勾销点击波纹
    Jetpack Compose 在点击时默认有波纹的,对 TV 来说并不需要;
    一开始我是参照 stackoverflow.com/a/66839858/… 解决的:
    @SuppressLint(“UnnecessaryComposedModifier”)
    fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = composed {
    clickable(
    indication = null,
    interactionSource = remember {MutableInteractionSource() },
    onClick = onClick
    )
    }
    复制代码
    然而每个点击都这么配置太麻烦了,所以我还是自定义了 LocalIndication:
    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) {...}

}
}
复制代码
留神 MaterialTheme 会配置 LocalIndication,所以要放在 MaterialTheme 外面去 CompositionLocalProvider() {};
@Composable
fun MaterialTheme(

...

) {

...
CompositionLocalProvider(
    LocalColors provides rememberedColors,
    LocalContentAlpha provides ContentAlpha.high,
    LocalIndication provides rippleIndication,
    LocalRippleTheme provides MaterialRippleTheme,
    LocalShapes provides shapes,
    LocalTextSelectionColors provides selectionColors,
    LocalTypography provides typography
) {...}

}
复制代码

  1. 注入小组件

我尝试在界面上加载一些小组件,如 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) }
}
复制代码
我原本是不想写 CollectComposeOwner 接口的,然而 @Composable 是 kcp 解决的,而 kapt 晚于 kcp,所以对 hilt 来说,@Composable (BoxScope) -> Unit 曾经通过编译变成 Function3<BoxScope, Composer, Int, Unit>,不便于收集了;
ksp 收集 @CollectCompose 我是这么写的:CollectComposeProcessor,生成的 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 == null) return
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
}

}
}
复制代码
右上角加了一个按钮是想试这个 radiography,很不错的一个库,输入以后界面的 Tree,反对 Compose,成果如下:

参考
文章

Jetpack Compose 博物馆
Jetpack Compose 在 Twidere X 中的实际总结 @Tlaster
Jetpack Compose 中显示富文本 @Tlaster
Jetpack Compose Side Effect:如何解决副作用 @fundroid
Focus in Jetpack Compose @Jamie Sanson
android-rethinking-package-structure @Joe Birch
Hilt 实战 | 创立利用级别 CoroutineScope

我的项目

TwidereX-Android @Tlaster
Dota-Info @Mitch Tabian
SampleComposeApp @Akila
ComposeVideoPlayer @Halil Ozercan
dpad-compose @Walter Berggren

原文:Seiko

退出移动版