与其他图片格式对比

WebP是一种由Google开发的现代图像格式,旨在提供更高的压缩率和更好的图像质量,同时保持与现有图像格式相似的视觉质量。与传统的JPEG、PNG和GIF格式相比,WebP具有以下优缺点:

优点:

  1. 更高的压缩率:WebP通常比JPEG格式具有更高的压缩率,可以减小图像文件的大小,加快网页加载速度。

  2. 更好的图像质量:相同文件大小下,WebP格式的图像质量通常比JPEG更好,可以减少图像的失真。

  3. 支持透明度:WebP格式支持透明度,可以创建带有半透明效果的图像,而JPEG不支持透明度。

  4. 动态图像支持:WebP格式支持动态图像,可以替代GIF格式,提供更高的压缩率和更好的图像质量。

缺点:

  1. 兼容性:WebP格式相对较新,不是所有的浏览器和应用程序都支持该格式,可能会导致兼容性问题。

  2. 编解码速度:WebP格式的编解码速度可能比JPEG和PNG慢一些,特别是在处理大型图像时。

  3. 动态图像性能:虽然WebP支持动态图像,但在某些情况下可能性能不如GIF格式。

总的来说,WebP格式在压缩率和图像质量方面有优势,特别适合用于网页图像的优化。但在一些特定情况下,如兼容性和动态图像性能方面可能存在一些缺点。

压缩处理

生成WebP图像通常使用Google的官方工具cwebp命令行工具或libwebp C库。
cwebp支持许多参数来控制输出的WebP图像的质量和大小。以下是一些常用的参数:

  • -q <float>:设置输出的图像质量,范围是0(最差)到100(最好)。默认值是75。

  • -r: 帧率

  • -m <int>:设置压缩效率,范围是0(最快)到6(最慢)。更高的值会产生更小但需要更长时间来编码的文件。默认值是4。

  • -lossless:生成无损的WebP图像。

  • -alpha_q <int>:设置透明度通道的质量。更低的值会产生更小的文件但透明度质量更差。

  • -resize <width> <height>:改变图像的大小到指定的宽度和高度。

  • -o <file>:指定输出文件的名称。

  • -v:显示详细的编码信息。

  • -h:显示帮助信息。

例如,以下命令将JPEG图像转换为质量为80的WebP图像:

|

1
cwebp -q 80 input.jpg -o output.webp

|

注意,cwebp的所有参数都是可选的。如果你不提供任何参数,cwebp将使用默认的设置来生成WebP图像。

另外,ffmpeg中也集成了cwebp,可以通过以下命令将视频文件转换成WebP图像:

|

1
ffmpeg -i input.mp4 -vcodec libwebp -vf scale=480:640 -lossless 0 -loop 0 -r 9 -q 8 output.webp

|

实践应用

背景:线上能将视频文件转成webp动图的工具的转换过程对用户来说都是一个黑盒操作,我们在使用的时候无法控制转换的参数,如转换的帧率、质量等等,这导致有时得到的webp动图不合期望,故这里开发了一个将视频文件转换成webp动图的mac桌面版工具。

项目链接
https://github.com/Darrenyuen/WebPTransTool

相关技术

  • KMP:开发桌面工具

  • 集成ffmep可执行文件:其中集成的cwebp提供了转换能力

效果展示

1.

导入需要转换的原始视频,目前仅支持mp4文件和mov文件

2.

导入后会弹出配置选择弹窗,默认配置是帧率为9、质量为8、使用旧编解码器,转换耗时长与视频大小及配置有关
!

3.

转换完成后可以看到

ViewModel是Google强推的Android开发最佳实践工具包Jetpack中的重要一员,用法比较简单,网上有许多介绍了其用法的文章,故此文不再介绍其用法。本文会从源码角度尝试解答ViewModel的几个相关问题:

  1. ViewModel实例如何创建与存储

  2. 什么时候会清空数据,为何配置发生变化的销毁不会导致数据清空

  3. ViewModel如何避免造成内存泄漏

  4. AndroidViewModel怎么做到不会发生内存泄漏的

1. ViewModel实例如何创建与存储

一切得从获取ViewModel具体实例的方法开始说起。
val viewModel = ViewModelProvider(this).get(TestViewModel::class.java) 为例说明:

|

1
public class ViewModelProvider {	private final Factory mFactory;  // 用于构造ViewModel实例	private final ViewModelStore mViewModelStore;  // 用于存储ViewModel实例		// 构造ViewModelProvider对象,Activity or Fragment都实现了ViewModelStoreOwner接口和ViewModelProvider.Factory接口	public ViewModelProvider(@NonNull ViewModelStoreOwner owner) {  		this(owner.getViewModelStore(), owner instanceof HasDefaultViewModelProviderFactory  		? ((HasDefaultViewModelProviderFactory) owner).getDefaultViewModelProviderFactory()  		: NewInstanceFactory.getInstance());  	}	public ViewModelProvider(@NonNull ViewModelStore store, @NonNull Factory factory) {  		mFactory = factory;  		mViewModelStore = store;  	}}

|

Activity和Fragment都实现了ViewModelStoreOwner和HasDefaultViewModelProviderFactory接口,分别用于创建ViewModelStore实例和ViewModel实例。

|

1
// Activitypublic class ComponentActivity extends androidx.core.app.ComponentActivity implements  ContextAware,  LifecycleOwner,  ViewModelStoreOwner,  HasDefaultViewModelProviderFactory,  SavedStateRegistryOwner,  OnBackPressedDispatcherOwner,  ActivityResultRegistryOwner,  ActivityResultCaller// Fragmentpublic class Fragment implements ComponentCallbacks, OnCreateContextMenuListener, LifecycleOwner,  ViewModelStoreOwner, HasDefaultViewModelProviderFactory, SavedStateRegistryOwner,  ActivityResultCaller

|

在构造ViewModelProvider对象实例时,ViewModelProvider会通过ViewModelStoreOwner类型入参获取到ViewModel实例的创建者和存储者。接下来看看ViewModel实例是如何被创建及存储的。

|

1
public <T extends ViewModel> T get(@NonNull Class<T> modelClass) {	// 把传入的类名组成一个key去读取ViewModel实例	String canonicalName = modelClass.getCanonicalName();  	if (canonicalName == null) {  	throw new IllegalArgumentException("Local and anonymous classes can not be ViewModels");  	}  	return get(DEFAULT_KEY + ":" + canonicalName, modelClass);  }

|

|

1
public <T extends ViewModel> T get(@NonNull String key, @NonNull Class<T> modelClass) {  	ViewModel viewModel = mViewModelStore.get(key);  	// 会在ViewModelStore的HashMap<String, ViewModel>类型成员mMap中通过组合的key去读取	if (modelClass.isInstance(viewModel)) {  		if (mFactory instanceof OnRequeryFactory) {  			((OnRequeryFactory) mFactory).onRequery(viewModel);  		}  		// 实例存在且合理,则返回读取到的ViewModel实例		return (T) viewModel;  	} else {  		//noinspection StatementWithEmptyBody  		if (viewModel != null) {  			// TODO: log a warning.  		}  	}  	if (mFactory instanceof KeyedFactory) {  		viewModel = ((KeyedFactory) mFactory).create(key, modelClass);  	} else {  		// 通过类反射的方式去构造实例		viewModel = mFactory.create(modelClass);  	}  	// 存储实例	mViewModelStore.put(key, viewModel);  	return (T) viewModel;  }

|

总结:在获取ViewModel的时候会先从ViewModelStore中的HashMap数据结构中读取实例,如果ViewModelStore中不存在该实例则通过反射方式创建实例并且存储到ViewModelStore中。

2. 什么时候会清空数据

|

1
// androidx.lifecycle.ViewModel.ktprotected void onCleared() {  }  @MainThread  final void clear() {  	mCleared = true;  	if (mBagOfTags != null) {  		synchronized (mBagOfTags) {  		for (Object value : mBagOfTags.values()) {   			closeWithRuntimeException(value);  		}  	}  	}  	onCleared();  }// androidx.lifecycle.ViewModelStore.ktpublic final void clear() {  	for (ViewModel vm : mMap.values()) {  		vm.clear();  	}  	mMap.clear();  }

|

ViewModel数据清空即被调用onCleared方法,而ViewModel的onCleared方法只会在ViewModelStore#clear中会被调用,阅读源码发现ViewModelStore的clear只会发生在宿主Activity#onDestroy或Fragment被移除时:

|

1
// androidx.activity.ComponentActivitygetLifecycle().addObserver(new LifecycleEventObserver() {  	@Override  	public void onStateChanged(@NonNull LifecycleOwner source,  	@NonNull Lifecycle.Event event) {  		if (event == Lifecycle.Event.ON_DESTROY) {  			// Clear out the available context  			mContextAwareHelper.clearAvailableContext();  			// And clear the ViewModelStore  			if (!isChangingConfigurations()) {  // 非配置变化引起的destroy				getViewModelStore().clear();  			}  		}  	}  });

|

总结:ViewModelStore中的map会在宿主Activity或Fragment销毁时被清理。

为何配置发生变化的销毁不会导致数据清空?

从上面注释的代码行中可以发现,只有是非配置变化导致的Activity#onDestroy才会触发ViewModelStore的clear方法。

但有个问题,我们知道尽管是配置变化引起的Activity销毁,但新建的Activity是一个全新的对象,那这个新Activity对象是如何继承前一个Activity中的ViewModel实例的呢?换句话说,或者是如何继承前一个Activity中的ViewModelStore实例的呢?

答案就在Activity的NonConfigurationInstances类型的mLastNonConfigurationInstances成员中。
因配置变化引起的Activity销毁重建属于异常销毁重建,NonConfigurationInstances类型成员的activity会持有之前被异常销毁的activity对象,也就持有了ViewModelStore实例,在每次Activity创建包括重建时都会先从NonConfigurationInstances对象中读取,NonConfigurationInstances对象没有才会创建新的ViewModelStore实例。

3. ViewModel如何避免造成内存泄漏

Google官方对于ViewModel生命周期与Activity生命周期的关系说明:

经分析,我们已经知道在ViewModel的宿主Activity或Fragment在被销毁时会调用ViewModel#clear,但假设我们在ViewModel中持有的宿主的引用,而又不在clear时释放引用,那此时便会造成内存泄漏。

一些避免ViewModel造成内存泄漏的建议和做法:

  1. 避免持有宿主的引用

  2. 在ViewModel#clear时释放宿主的引用、取消网络请求、关闭数据库连接,如:
    |

1
class MyViewModel : ViewModel() {    private var activityRef: WeakReference<Activity>? = null    fun setActivity(activity: Activity) {        activityRef = WeakReference(activity)    }    fun clear() {        activityRef?.clear()        activityRef = null    }}

|

4. AndroidViewModel怎么做到不会发生内存泄漏的

|

1
public class AndroidViewModel extends ViewModel {  	@SuppressLint("StaticFieldLeak")  	private Application mApplication;  	  	public AndroidViewModel(@NonNull Application application) {  	mApplication = application;  	}  	  	/**  	* Return the application.  	*/  	@SuppressWarnings("TypeParameterUnusedInFormals")  	@NonNull  	public <T extends Application> T getApplication() {  		return (T) mApplication;  	}  }

|

来看下AndroidViewModel的源码,AndroidViewModel是ViewModel的一个子类,它提供了一个Application对象的引用,通过在AndroidViewModel中使用Application Context而不是Activity或Fragment的Context来避免内存泄漏。这是因为Application Context的生命周期与应用程序的生命周期相同,而Activity的生命周期是短暂的,当Activity被销毁时,它的Context也会被销毁,如果在ViewModel中使用了Activity的Context,就可能会导致内存泄漏。

本人所作的内部技术分享,脱敏同步

项目介绍

项目背景

为了解决视频转WebP动图的痛点问题

  1. 用的是外部工具,黑盒过程不可控

  2. webp体积过大,存在OOM风险

  3. 帧率、质量没有统一标准

期望提供一个视频转webp工具约束生产流程

方案选型

业务后台(链路长) vs 脚本工具(门槛高) vs KMP Compose(技术栈匹配能内部闭环)

建设过程

新建项目方式:

  1. 官方模板代码:https://github.com/JetBrains/compose-multiplatform-template

  2. Kotlin Multiplatform Mobile plugin

实践项目结构介绍

Compose编程关键点

•composable 可组合的UI

•mutableStateOf 更新发起点

•remember 刷新前后状态继承

|

1
val taskList = remember { mutableStateOf(LinkedList<TaskData>()) } // list是更新点且可继承状态	Box(Modifier.wrapContentHeight(), contentAlignment = Alignment.TopCenter) {   ListTaskView(taskList.value)} // view data bindtaskData.value = TaskData(inputPath = inputPath.value, outputPath = outputPath.value)taskList.value.addFirst(taskData.value) // list新增一个条目 // 条目状态变更taskData.value.inputPath = filePathtaskData.value.outputPath = outputPathtaskData.status.value = ConvertSuccess

|

KMP介绍

KMM, KMP, KMP Compose到底都是个啥?

KMM(Kotlin Multiplatform Mobile)

KMP(Kotlin Multiplatform)

KMP Compose(Kotlin Multiplatform Compose)

跨平台框架对比

|

|
|
|
|

|

| Flutter
| React Native
| Kotlin Multiplatform
|

|
UI支持
| 较强
| 较强
| 强
|

|
开发语言
| Dart
| Javascript
| Kotlin
|

|
迁移成本
| 高
| 高
| 低
|

|
运行环境
| Flutter引擎
| JSCore
| 纯原生
|

|
性能
| 较高
| 一般
| 高
|

KMP、Flutter和React Native都是用于开发跨平台移动应用的框架,各有其优缺点。

KMP发展趋势

应用阵营:百度、快影、McDonald’s、Netflix… https://www.jetbrains.com/help/kotlin-multiplatform-dev/case-studies.html

KMP跨平台原理

KMP 的核心原理是通过 Kotlin K2编译器将共享的 Kotlin 代码编译成适用于目标平台的原生代码,从而实现在不同平台上共享代码的目的。

项目结构与运行

项目结构


共享模块中的平台命名文件和平台特定模块中的文件有何区别?平台特定模块更多是作为一个应用入口。

依赖管理

公共依赖(e.g. Koin, Apollo, Okio

特定平台依赖 (e.g. Glide, Fresco, AFNetworking, YYModel)

构建运行

项目形态

  • 共享一段逻辑
    共享应用的独立共同逻辑。

  • 共享逻辑但保持原生UI
    开始新项目时使用Kotlin Multiplatform,只需实现一次数据处理和业务逻辑,并且保持 UI 原生。

  • 共享逻辑与UI
    使用Compose Multiplatform提高开发效率,共享逻辑与UI的代码。

What’s more

官方示例:https://github.com/android/kotlin-multiplatform-samples

Reference

KMP Development
跨平台开发的后起之秀
Kotlin Multiplatform Mobile 跨平台原理

由于一进入工作状态进长期久坐盯着电脑屏幕,长期以往始觉肩酸眼涩。所以想开发一个运行于IDE中的定时提醒插件,定时提醒自己离开座位远眺放松。

新建项目

Android Studio是基于IntelliJ IDEA构建的,因此要开发的也是基于IDEA平台的插件。插件的模板代码可以在IDEA上新建项目时勾选,也可以在官网上克隆使用:

  1. 在IDEA中创建一个新的插件项目:New Project -> Generators IDE Plugin -> Project Name -> Type choose Plugin not Theme

  2. 在官方仓库上克隆使用:IDEA platform plugin template
    官方文档中提到仓库中的模板代码预设了gradle配置支持,使用IDE因此本次实践是使用官方仓库上的模板代码。

开发插件

1.

运行模板代码
把官方模板仓库中的代码克隆下来,使用IDEA打开,无需额外设置其他配置,只需要等sync完成后选择run plugin的gradle选项即可运行。模板代码中预设的插件名是MyToolWindow,运行效果如图:

2.

绘制控件
本意是做一个定时提醒插件,分析需求后,只需要以下几个交互控件:

  • 设置提醒时间间隔

  • 确认提醒按钮

  • 取消提醒按钮

  • 提醒控件
    使用者可以设置提醒时间间隔,确认提交后定时提醒任务便开始运行,每隔指定时间会在IDE右下角进行弹窗提醒;用户可以通过取消提醒来取消提醒任务。
    IDEA插件是基于Swing使用Java/Kotlin进行开发的,所以可以使用Swing中的控件库来绘制UI。基于需求绘制及运行的结果如图所示:

1.

持久化数据
考虑到IDE可能会被关闭重启,不想每次都要手动输入一遍提醒数据,所以需要通过持久化把设置的阈值记录下来以便下次启动的时候还可以继续使用。IDEA官方提供了持久化的接口,开发者只需要实现并声明需要持久化字段的读与写就好。

|

1
@State(name = "state", storages = [Storage(value = "state.xml")])  class PersistentState private constructor(): PersistentStateComponent<PersistentState> {        companion object {          fun getInstance(): PersistentState {              return ApplicationManager.getApplication().getService(PersistentState::class.java)          }      }        var remindIntervalTime: Int? = null          override fun getState(): PersistentState? {          return this      }        override fun loadState(p0: PersistentState) {          XmlSerializerUtil.copyBean(p0, this)      }  }

|

示例中实现了官方提供的PersistentStateComponent接口,里面指明了需要持久化数据的名词及类型,并且以单例的形式提供给调用者使用。

|

1
写:persistentService.remindIntervalTime = value读:val value = PersistentState.getInstance().remindIntervalTime

|

2.

发送通知
IDEA中的弹窗种类有很多,提醒插件的需求是在IDE右下角出现提示弹窗,所以选择使用Notification。

|

1
object RemindNotify {      fun showRemindNotify() {          Notifications.Bus.notify(              Notification(                  "Print",                  "Remind",                  "It's time to have a rest",                  NotificationType.WARNING              )          )      }  }

|

3.

更新Icon
最后,设置插件的Icon让插件更有辨识度。在root_project/src/main/resource/META-INF中新增pluginIcon.svg文件,运行打包时,会自动应用其为插件ICON。

打包插件

将插件打包成jar或zip文件,并发布到插件市场或其他适当的位置。打包后的文件存在于root_project/build/distributions文件夹下。本地使用方式是在IDE中进入插件配置页应用硬盘中的插件:Settings->Plugins->Manager->Install Plugin from disk->选择zip包->apply->重启IDE便能生效。

项目地址

https://github.com/Darrenyuen/Reminder-Plugin

参考

How to build a plugin for IDEA-based platform
https://zhuanlan.zhihu.com/p/608825921

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

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

改造数据源

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

Viewpager2自动切换

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

相关核心代码如下:

|

1
// 改造数据源,原数据为[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
fun findPosition(songId: String): Int {    return dataList.indexOfFirst { it.songId == songId }    }

|

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

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

优化后的数据源

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

|

1
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
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状态监听器中的自动切换逻辑,播放卡片无限滑动的效果就实现了。

模块化与组件化

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

  • 组件化:组件相当于库,组件化是指把一些能在项目里或者不同类型项目中可复用的代码进行工具性的封装,抽离为单个组件,按照功能逻辑进行划分并控制粒度便于项目的维护和开发。多个组件也可以组合成组件库,方便调用和复用,组件间也可以嵌套,小组件组合成大组件。

  • 模块化:更多从业务逻辑出发,分属同一功能/业务的代码进行隔离(分装)成独立的模块,,让业务逻辑在模块内内聚,可以独立运行,以页面、功能或其他不同粒度划分程度不同的模块,位于业务框架层,模块间通过接口调用,目的是降低模块间的耦合,由之前的主应用与模块耦合,变为主应用与接口耦合,接口与模块耦合。

为什么要模块化

  • 提升编译速度:模块化可以将一个大的项目分解为多个小的模块,每个模块都可以独立编译。这样,当你修改了某个模块的代码后,只需要重新编译这个模块,而不需要重新编译整个项目。这大大减少了编译的时间,从而提高了编译速度。

  • 提升开发效率:模块化可以使每个模块都可以独立进行开发测试,开发者可以专注于模块内的开发;同时,模块化通过分工合作、代码重用、模块独立和易于扩展来提升开发效率。

可维护性强:同一业务的代码在模块内聚合,每个模块都有自己的职责和功能。这样,当一个模块出现问题时,只需要修复该模块,而不需要影响整个应用程序。

-

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

模块化实践方案

模块化中的重要课题是:

  1. 主模块与子模块解耦合,在安卓工程上的表现是主模块要依赖子模块来使用子模块中的能力,因此主模块可以依赖子模块,但子模块不能依赖主模块,否则就是非法环形依赖了。

  2. 子模块与主模块通信,解耦合带来的问题是在子模块中不能依赖主模块,也就不能直接使用主模块中的能力了,因此需要解决特定情况下子模块使用主模块能力的问题。

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

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

隐式依赖(SPI)

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

逻辑关系如图所示:

具体实现:

  1. 在接口模块中声明接口
    |
1
package com.example.spi.api    interface ILogInterface {      fun logD(msg: String)  }

|

  1. 在实际业务模块中实现接口
    |
1
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)      }  }

|

  1. 在接口模块中的resources/META-INF/services目录里创建服务接口同名文件,接口同名文件内部是实现类的包路径

  2. 在主模块中通过ServiceLoader与接口模块中的接口使用实际业务模块中的能力
    |

1
ServiceLoader.load(ILogInterface::class.java, classLoader).iterator().next()?.logD("test")

|

显式依赖

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

逻辑关系如图所示:

具体实现:

1.

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

|

1
// 子模块通过接口模块提供给主模块的能力,由子模块实现interface IService {  	fun getSubActivity(): Activity }// 主模块通过接口模块提供给子模块的能力,由主模块实现interface IContext {  	val util: IUtil   }

|

2.

在子模块中实现接口,并提供接口接受主模块注入的能力

|

1
// 子模块实现接口  @Keep  class ServiceImpl(context: IContext): IService {    	init {  		HostContextForService.init(context)  // 接受主模块注入的能力,后续通过访问HostContextForService即可使用主模块的能力	}  	// 向外暴露子模块的能力	override 	fun getSubActivity(): Activity {		return SubActivity()	}}

|

3.

主模块通过接口模块使用子模块的能力,并注入主模块的能力

|

1
// 实现接口,向子模块提供能力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能力快速与子模块通信,但无法向子模块注入主模块中的能力;第二种显式依赖,需要开发者手动编写运行时反射代码以实现主模块对子模块的依赖,但可以在反射时注入主模块的一些能力,来让子模块也能调用主模块的一些能力。

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堆栈

  • hook法查清难以确认的调用链,如Bundle#put大体积数据、View#requestLayout。饿了么的lancet…

  • 日志打印方法调用链
    |

1
val stackTrace = Throwable().fillInStackTrace()Log.d(TAG, "generateAndShow", stackTrace)

|

0%