0%

在一次需求中需要实现播放卡片无限滚动的效果,现有代码使用的是Viewpager2+Fragment的框架,但官方的Viewpager2并没有提供无限滑动的API,所以这里摸索了一套方案记录如下。

在动手coding之前在网上调研了相关效果的实现方案,大致有两种思路:一种是在FragmentStateAdapter#getItemCount()中返回Integer.MAX_VALUE通过不断填充内容使得滑不动尽头达到无限滑动的效果;另一种则是在列表前后各加一个元素,再配合自动切换实现无限滑动。需求实现中采用的是第二种思路。

改造数据源

前后各加一个元素的具体实现,是在构造Adapter数据的时候分别将数据列表的最后一个元素和第一个元素拷贝添加至数据列表的头部和尾部。即数据列表的内容由[A, B, C] 变为 [C, A, B, C, A]。

Viewpager2自动切换

注册Viewpager2页面状态改变的监听器,在回调中判断如果当前处于第一张卡片(即C`处)则自动切换到倒数第二张卡片(即C处),如果当前当前处于最后一张卡片则自动切换到第二张,通过这样一种方式可以让用户无限滑动卡片。

相关核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 改造数据源,原数据为[RED, YELLOW, BLUE]
dataList = arrayListOf<Int>(Color.BLUE, Color.RED, Color.YELLOW, Color.BLUE, Color.RED)

// 在viewpager2的页面状态监听器中实现自动切换
viewPager2?.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {
super.onPageScrolled(position, positionOffset, positionOffsetPixels)

if (positionOffset != 0f) return

// itemCount > 1要支持循环滑动
if (itemCount > 1) {
if (position == 0) {
viewPager?.setCurrentItem(itemCount - 2, false)
} else if (position == itemCount - 1) {
viewPager?.setCurrentItem(1, false)
}
}
}
})

该示例代码在Demo中运行是能达到预期效果的,但是当在主工程上实现时发现[A, B, C] to [C, A, B, C, A]这个对数据源的改造出现了水土不服,原因是Adapter中有一个通过songID获取当前索引的方法:

1
2
3
fun findPosition(songId: String): Int {
return dataList.indexOfFirst { it.songId == songId }
}

所以存在一个问题,即在 [C, A, B, C, A]中,当想获取C的位置时返回的却是C`的索引。这个方法在原有代码中被多处调用,改造这个方法也存在一定风险,本着不动现有逻辑的原则,唯有探索其他方法了。

之所以这一种方案会出现水土不服,是因为改造数据源时对数据元素进行了深拷贝,导致执行findPostion可能出错。能不能避开通过深拷贝的方式来适配当前的findPosition逻辑呢?答案是肯定的。

优化后的数据源

既然通过深拷贝元素对象不可行,那干脆不进行对象的拷贝了。在数据列表前后各加一个元素这个方案优化后的数据源实现方式是在数据列表前后各加一个元素类型的空对象,即只创建元素对象,不对其进行使用。

1
2
3
4
5
6
7
8
if (list.size > 1) {
// 构造循环列表 [A, B, C] to [obj, A, B, C, obj]
dataList.add(PlaySongInfo())
dataList.addAll(list)
dataList.add(PlaySongInfo())
} else {
dataList.addAll(list)
}

由于改造后的数据源有前后各有一个仅作填充使用的对象,所以在使用列表数据的时候需要做一下index的转换,以下是使用数据源创建fragment的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
override fun createFragment(position: Int): Fragment {
val dataSize = dataList.size
// 卡片大于1时支持循环滑动
val realPosition = if (dataSize > 1) {
if (position == 0) {
dataSize - 2
} else if (position == dataSize - 1) {
1
} else {
position
}
} else {
position
}
val songInfo = dataList[realPosition]
return TargetFragment.newInstance(songInfo)
}

至此,优化后的数据源再配合上Viewpager2状态监听器中的自动切换逻辑,播放卡片无限滑动的效果就实现了。

Tool

Skill

  • 实时导出日志:adb logcat > logcat.log
  • adb shell am start -D -n com.darrenyuen.demo/.DemoActivity 该命令会等debug attach后才启动指定acitivity
  • git bisect good/bad 二分定位出问题的commit
  • adb logcat -b crash 打印最近一次的crash堆栈

模块化与组件化

提及模块化,常常会想到组件化,这两者都是软件开发中的设计理念,相似但不尽相同。

  • 组件化:组件相当于库,组件化是指把一些能在项目里或者不同类型项目中可复用的代码进行工具性的封装,抽离为单个组件,按照功能逻辑进行划分并控制粒度便于项目的维护和开发。多个组件也可以组合成组件库,方便调用和复用,组件间也可以嵌套,小组件组合成大组件。
  • 模块化:更多从业务逻辑出发,分属同一功能/业务的代码进行隔离(分装)成独立的模块,,让业务逻辑在模块内内聚,可以独立运行,以页面、功能或其他不同粒度划分程度不同的模块,位于业务框架层,模块间通过接口调用,目的是降低模块间的耦合,由之前的主应用与模块耦合,变为主应用与接口耦合,接口与模块耦合。

为什么要模块化

  • 提升编译速度:模块化可以将一个大的项目分解为多个小的模块,每个模块都可以独立编译。这样,当你修改了某个模块的代码后,只需要重新编译这个模块,而不需要重新编译整个项目。这大大减少了编译的时间,从而提高了编译速度。
  • 提升开发效率:模块化可以使每个模块都可以独立进行开发测试,开发者可以专注于模块内的开发;同时,模块化通过分工合作、代码重用、模块独立和易于扩展来提升开发效率。
  • 可维护性强:同一业务的代码在模块内聚合,每个模块都有自己的职责和功能。这样,当一个模块出现问题时,只需要修复该模块,而不需要影响整个应用程序。

  • 提高可扩展性:模块化可以将应用程序拆分成多个独立的模块,每个模块都可以单独扩展或与其他模块组合扩展。这样可以提高应用程序的可扩展性,使其更容易适应不同的需求和场景。

模块化实践方案

模块化中的重要课题是:

  1. 主模块与子模块解耦合,在安卓工程上的表现是主模块要依赖子模块来使用子模块中的能力,因此主模块可以依赖子模块,但子模块不能依赖主模块,否则就是非法环形依赖了。
  2. 子模块与主模块通信,解耦合带来的问题是在子模块中不能依赖主模块,也就不能直接使用主模块中的能力了,因此需要解决特定情况下子模块使用主模块能力的问题。

本文介绍两种常见常用的模块化方案。

以下示例都假设有主模块(app)、接口模块(api)和子模块(impl)三种模块角色

隐式依赖(SPI)

SPI(service provider interface),通过使用Java提供的ServiceLoader接口,在工程结构的module/resources/META-INF/services目录里创建服务接口同名文件,文件内是实现类的包路径,从而在运行时动态通过该服务接口名字查找到实现类的名字,并装载实例化,完成模块的注入。

逻辑关系如图所示:

具体实现:

  1. 在接口模块中声明接口
    1
    2
    3
    4
    5
    package com.example.spi.api  

    interface ILogInterface {
    fun logD(msg: String)
    }
  2. 在实际业务模块中实现接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    package com.example.spi.impl  

    import android.util.Log
    import com.example.spi.api.ILogInterface

    class Logger: ILogInterface {
    companion object {
    private const val TAG = "Logger"
    }
    override fun logD(msg: String) {
    Log.d(TAG, msg)
    }
    }
  3. 在接口模块中的resources/META-INF/services目录里创建服务接口同名文件,接口同名文件内部是实现类的包路径
  4. 在主模块中通过ServiceLoader与接口模块中的接口使用实际业务模块中的能力
    1
    ServiceLoader.load(ILogInterface::class.java, classLoader).iterator().next()?.logD("test")

显式依赖

显示依赖也是借助运行时反射来达到依赖的,但这交由开发者来手动实现,并非同spi方式只需在接口模块编写配置文件即可。

逻辑关系如图所示:

具体实现:

  1. 同样地在接口模块中声明接口,主模块与子模块不直接通信,而是通过接口模块这个桥梁实现通信

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 子模块通过接口模块提供给主模块的能力,由子模块实现
    interface IService {
    fun getSubActivity(): Activity
    }

    // 主模块通过接口模块提供给子模块的能力,由主模块实现
    interface IContext {
    val util: IUtil
    }
  2. 在子模块中实现接口,并提供接口接受主模块注入的能力

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 子模块实现接口  
    @Keep
    class ServiceImpl(context: IContext): IService {

    init {
    HostContextForService.init(context) // 接受主模块注入的能力,后续通过访问HostContextForService即可使用主模块的能力
    }

    // 向外暴露子模块的能力
    override
    fun getSubActivity(): Activity {
    return SubActivity()
    }
    }
  3. 主模块通过接口模块使用子模块的能力,并注入主模块的能力

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 实现接口,向子模块提供能力
    class ContextImpl: IContext {
    override val util: IUtil
    get() = Util()
    }

    // 使用子模块的能力
    fun initService() {
    runCatching {
    if (ServiceManager.getService(IService::class.java) == null) {
    val subModuleHost = ContextImpl() // 向子模块提供能力
    // 运行时构造子模块中的实例,并注入主模块的能力
    val serviceImplClass = Class.forName("com.example.impl.service.ServiceImpl")
    val constructor = serviceImplClass.getConstructor(IContext::class.java)
    val serviceImpl = constructor.newInstance(subModuleHost) as IService
    ServiceManager.registerIfNull(IService::class.java, serviceImpl)
    // 后续通过 ServiceManager获取子模块的实例并使用子模块
    }
    }.onFailure {
    Log.e("ServiceRegistry", "init template show service error.", it)
    }
    }

至此,便实现了主模块与子模块间的通信,并且可以在子模块中不用依赖主模块也能使用主模块中的能力了。

可能有人会觉得需要手动显示依赖的方式会比较麻烦,但正因为显式反射依赖,开发者可以在反射构造子模块中实例时注入主模块中的一些能力,这样能更方便地实现从主模块拆分子模块的模块化工作。

总结

本文介绍了模块化的两种方案,两种方案都是除了主模块与子模块外还有一个接口层模块,接口层模块的存在可以更好的隔绝主模块与子模块,以实现更好的解耦合。
其中,第一种隐式依赖,主模块能通过语言级的spi能力快速与子模块通信,但无法向子模块注入主模块中的能力;第二种显式依赖,需要开发者手动编写运行时反射代码以实现主模块对子模块的依赖,但可以在反射时注入主模块的一些能力,来让子模块也能调用主模块的一些能力。