我是一条鱼

想做一个发光的鱼

谷歌官方推荐的ExoPlayer的基本使用

简介

在日常开发中,自然少不了使用播放器,特别是现在短视频流行的情况下,几乎所有的App都有播放视频的功能,一提到播放器,相信很多开发者都知道GSYPlayer和IjkPlayer。这两款开源的播放器功能强大,能满足日常开发需要。但是需要引入第三方依赖,加载一些很多不必要的资源,我们现在的App由于历史原因还有很多不同的播放器,导致包体积直线上升。

使用过GSYPlayer的小伙伴都知道,这个播放器是支持切换内核的。一个是ijkPlayer内核和一个ExoPlayer的内核。其实认真了解ExoPlayer的小伙伴都知道,使用ExoPlayer更简单,更方便。毕竟 谷歌出品,必属精品!下面我们就来介绍一下怎么使用ExoPlayer?

基本使用

ExoPlayer最新版本是media3,这里使用的版本较低,主要是因为主线App的targetSDK版本是30,一旦超过了2.15.1就必须升级Android SDK版本。话不多说,直接上代码。

引入依赖

1
implementation 'com.google.android.exoplayer:exoplayer:2.15.1'

创建ExoPlayer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
val loadControl = DefaultLoadControl.Builder()
.setPrioritizeTimeOverSizeThresholds(true)//缓冲时时间优先级高于大小
.setBufferDurationsMs(MIN_BUFFER_MS, MAX_BUFFER_MS, PLAYBACK_BUFFER_MS, REBUFFER_MS)
.build()
if (mPlayer == null){
mPlayer = activity?.let { SimpleExoPlayer.Builder(activity).setLoadControl(loadControl).build() }
}
//重复模式
mPlayer?.repeatMode = Player.REPEAT_MODE_ALL
//设置seekto参数
mPlayer?.setSeekParameters(SeekParameters.EXACT)
//设置监听
mPlayer?.setVideoFrameMetadataListener(this)
mPlayer?.addListener(this)

设置视频资源

1
2
3
4
5
6
7
8
9
list.forEach {
val newMediaItem = MediaItem.Builder()
.setUri(it.path)
.build()
mPlayer?.addMediaItem(newMediaItem)
}

mPlayer?.playWhenReady = true
mPlayer?.prepare()

播放视频

1
mPlayer?.play()

暂停视频

1
mPlayer?.pause()

停止播放

1
mPlayer?.stop()

绑定Surface/SurfaceView/TextureView

1
2
3
4
5
6
//绑定surface
mPlayer?.setVideoSurface(surface)
//绑定surfaceView
mPlayer?.setVideoSurfaceView(surfaceView)
//绑定TextureView
mPlayer?.setVideoSurfaceView(textureView)

seekTo

1
2
3
4
//seekto指定时间
mPlayer?.seekTo(time)
//seekto指定视频的时间
mPlayer?.seekTo(currentIndex,time)

指定区间播放

1
2
3
4
5
MediaItem.Builder()
.setUri(it.path)
.setClipStartPositionMs(it.imageStartTime)
.setClipEndPositionMs(it.imageEndTime)
.build()

优点:使用简单,谷歌出品,懂得都懂。最低支持API 14,支持的格式丰富。

缺点:需要自己计算视频播放进度。

总结

ExoPlayer不仅支持单个视频播放,也支持视频列表播放,切换下一个视频和指定时间段播放视频等丰富的功能。ExoPlayer还提供了UI框架,小编这里使用surface是因为需要自定义渲染效果。

小编在使用过程中踩了很多坑,如果读者在使用ExoPlayer的过程中,遇到无法解决的问题可以私信小编。

Android WiFi工具类

当开发基于WiFi的Android应用时,对WiFi连接、管理和配置的操作非常重要。为了简化这些操作,我们可以使用一个名为 WifiUtils 的工具类。这个工具类提供了一系列方法,可以方便地执行诸如检查WiFi状态、打开或关闭WiFi、扫描可用的WiFi网络、连接到指定的WiFi网络、断开当前连接的WiFi网络等操作。

这个工具类还提供了一些辅助方法,比如获取当前连接的WiFi信息、检查是否有当前可用的WiFi连接、添加WiFi配置到系统、创建WiFi配置、获取是否已经存在的配置、移除同名WiFi、判断扫描结果中是否包含了特定名称的WiFi等。这些方法使得WiFi的管理和配置变得更加便捷和灵活。

使用这个 WifiUtils 工具类,我们可以轻松地在Android应用中执行WiFi相关的操作,无论是连接到特定的WiFi网络,还是管理已经连接的网络,都可以通过简单的调用实现。这样可以极大地简化了开发过程,提高了应用的可靠性和用户体验。 WifiUtils 工具类为Android开发人员提供了一个简单而强大的工具,可以在开发过程中更方便地管理和配置WiFi网络。通过使用这个工具类,开发人员可以更专注于应用的核心功能,而无需过多关注底层的WiFi管理细节。

1.WifiUtils 工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
import android.content.Context;
import android.net.wifi.WifiConfiguration;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import java.util.List;

public class WifiUtils {
private final WifiManager wifiManager;

public WifiUtils(Context context) {
wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
}

// 检查WiFi是否可用
public boolean isWifiEnabled() {
return wifiManager.isWifiEnabled();
}

// 打开WiFi
public void openWifi() {
if (!wifiManager.isWifiEnabled()) {
wifiManager.setWifiEnabled(true);
}
}

// 关闭WiFi
public void closeWifi() {
if (wifiManager.isWifiEnabled()) {
wifiManager.setWifiEnabled(false);
}
}

// 扫描WiFi网络
public void scanWifi() {
wifiManager.startScan();
}

// 获取扫描到的WiFi列表
public List<WifiConfiguration> getScanResults() {
return wifiManager.getConfiguredNetworks();
}

// 获取当前连接的WiFi
public WifiInfo getConnectedWifiInfo() {
return wifiManager.getConnectionInfo();
}

// 判断是否有当前可用的WiFi连接
public boolean isWifiConnected() {
WifiInfo wifiInfo = wifiManager.getConnectionInfo();
return wifiInfo != null && wifiInfo.getNetworkId() != -1;
}

// 关闭所有连接
public void disconnectWifi() {
wifiManager.disconnect();
}

// 连接WiFi
public void connectToWifi(WifiConfiguration wifiConfig) {
int netId = wifiManager.addNetwork(wifiConfig);
wifiManager.enableNetwork(netId, true);
}

// 移除WiFi
public void removeWifi(int netId) {
wifiManager.removeNetwork(netId);
wifiManager.saveConfiguration();
}

// 添加WiFi到系统
public int addNetwork(WifiConfiguration wifiConfig) {
return wifiManager.addNetwork(wifiConfig);
}

// 创建配置
public WifiConfiguration createWifiConfig(String ssid, String password, int type) {
WifiConfiguration config = new WifiConfiguration();
config.SSID = "\"" + ssid + "\"";
switch (type) {
case 1:
config.hiddenSSID = true;
config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE);
break;
case 2:
config.hiddenSSID = true;
config.wepKeys[0] = "\"" + password + "\"";
config.allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN);
config.allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.SHARED);
config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE);
config.wepTxKeyIndex = 0;
break;
case 3:
config.preSharedKey = "\"" + password + "\"";
break;
}
return config;
}

// 获取是否已经存在的配置
public WifiConfiguration isExist(String ssid) {
List<WifiConfiguration> existingConfigs = wifiManager.getConfiguredNetworks();
for (WifiConfiguration existingConfig : existingConfigs) {
if (existingConfig.SSID.equals("\"" + ssid + "\"")) {
return existingConfig;
}
}
return null;
}

// 去除同名WIFI
public void removeSameWifi(String ssid) {
List<WifiConfiguration> existingConfigs = wifiManager.getConfiguredNetworks();
for (WifiConfiguration existingConfig : existingConfigs) {
if (existingConfig.SSID.equals("\"" + ssid + "\"")) {
wifiManager.removeNetwork(existingConfig.networkId);
}
}
}

// 判断一个扫描结果中,是否包含了某个名称的WIFI
public boolean isScanResultExist(String ssid, List<WifiConfiguration> existingConfigs) {
for (WifiConfiguration existingConfig : existingConfigs) {
if (existingConfig.SSID.equals("\"" + ssid + "\"")) {
return true;
}
}
return false;
}

// WiFi安全类型枚举
public enum WifiSecurityType {
OPEN, WEP, WPA
}
}

2.WifiUtils 简单使用实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import android.os.Bundle;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import java.util.List;

public class MainActivity extends AppCompatActivity {

WifiUtils wifiUtils;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

wifiUtils = new WifiUtils(this);

// 检查WiFi是否可用
boolean isWifiEnabled = wifiUtils.isWifiEnabled();

// 打开WiFi
wifiUtils.openWifi();

// 关闭WiFi
wifiUtils.closeWifi();

// 扫描WiFi网络
wifiUtils.scanWifi();

// 获取扫描到的WiFi列表
List<WifiConfiguration> scanResults = wifiUtils.getScanResults();

// 获取当前连接的WiFi
wifiUtils.getConnectedWifiInfo();

// 判断是否有当前可用的WiFi连接
boolean isWifiConnected = wifiUtils.isWifiConnected();

// 关闭所有连接
wifiUtils.disconnectWifi();

// 创建一个新的WiFi配置
WifiUtils.WifiConfiguration wifiConfig = wifiUtils.createWifiConfig("YourSSID", "YourPassword", 3);

// 连接到WiFi
wifiUtils.connectToWifi(wifiConfig);

// 移除WiFi
wifiUtils.removeWifi(0);

// 添加WiFi到系统
int netId = wifiUtils.addNetwork(wifiConfig);

// 获取是否已经存在的配置
WifiUtils.WifiConfiguration existingConfig = wifiUtils.isExist("YourSSID");

// 去除同名WIFI
wifiUtils.removeSameWifi("YourSSID");

// 判断一个扫描结果中,是否包含了某个名称的WIFI
boolean isScanResultExist = wifiUtils.isScanResultExist("YourSSID", scanResults);

// 在这里根据需要进行其他操作
// ...

// 示例:显示Toast提示信息
Toast.makeText(this, "WiFi Enabled: " + isWifiEnabled, Toast.LENGTH_SHORT).show();
}
}

Android 12 适配简介

那么 Android 12 到底会给我们带来什么样的改变,作为一名从业者,我们又该分配多少资源以及如何去对我们的产品进行适配呢。

适配

通常我们说的适配,严格来讲分成两个阶段。 第一部分是通配性问题, 即使我不对我的项目进行任何修改,不修改 TargetSdkVersion 也需要进行关注, 个人喜欢把它叫通配性问题。 第二部分则是全面适配问题, 即 TargetSdkVersion 等于 31 才需要去关注的问题. 这里主要和大家讲讲通配性问题。 如果大家需要迁移到 Android 12, 设置 TargetSdkVersion 成 31, 可以根据自身产品使用的东西并参考 官方文档 来进行迁移。

通配性问题

通配性问题,咱的定义是,无论你使用哪个版本的 TargetSdkVersion, 只要跑在 Android 12 上,你都需要关注。这里个人节选了几个比较重要的,一起来看看吧.

Splash Screen

Android 12 增加了系统默认的 APP 启动页,该 APP 启动页会使用APP定义的主题和ICON生成。这可能对很多开发者来说是一个比较大的困恼,如果不做任何适配,产生的结果可能如下:

  1. 如果你原本使用 android:windowBackground 实现了启动页,你的实现会被默认的启动页样式替换。
  2. 如果你使用了一个额外的 Activity 作为启动页,则会先弹出系统默认启动页,再弹出你实现的启动页,用户有幸可以享受两次闪屏了。

目前谷歌的适配方案只提供了设置主题的方式,至于原本使用额外 Activity 作为启动页的方式,谷歌爸爸和蔼的和我们说,你可以选择不管或者去掉 Activity并使用设置主题方式来兼容. 具体方法如下:

  • 设置 compileSdkVersion 和引入库
1
2
3
4
5
6
7
8
9
10
build.gradle

android {
compileSdkVersion 31
...
}
dependencies {
...
implementation 'androidx.core:core-splashscreen:1.0.0-alpha01'
}
  • 创建闪屏主题,继承 Theme.SplashScreen, 设置 postSplashScreenThemewindowSplashScreenAnimatedIcon, 其他字段可选.如:
1
2
3
4
5
6
7
8
9
10
11
12
13
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
// Set the splash screen background, animated icon, and animation duration.
<item name="windowSplashScreenBackground">@color/...</item>

// Use windowSplashScreenAnimatedIcon to add either a drawable or an
// animated drawable. One of these is required.
<item name="windowSplashScreenAnimatedIcon">@drawable/...</item>
<item name="windowSplashScreenAnimationDuration">200</item> # Required for
# animated icons

// Set the theme of the Activity that directly follows your splash screen.
<item name="postSplashScreenTheme">@style/Theme.App</item> # Required.
</style>
  • Manifest 使用对应的主题
1
2
3
4
5
<manifest>
<application android:theme="@style/Theme.App.Starting">
<!-- 或者 -->
<activity android:theme="@style/Theme.App.Starting">
...
  • 代码中,在 setContentView 前调用 installSplashScreen, 以 Java 为:
1
2
3
4
5
6
7
8
9
10
11
public class SplashScreenSampleActivity extends Activity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

// Handle the splash screen transition.
SplashScreen splashScreen = SplashScreen.installSplashScreen(this);
setContentView(R.layout.main_activity);
}
}

installSplashScreen 会返回闪屏对象,其本身也可以进行一些属性设置, 至此,如上就是谷歌推荐的 SplashScreen 的适配方法

OverScroll 滚动动画增加

Android 12 修改了 OverScroll 的效果动画,从原来的拉到底部显示蓝光,修改成了拉到变形弹弹弹的动画。如下图

如果你需要特别处理 OverScroll 的动画或者动作,谷歌增加了 float getDistance()float onPullDistance(float deltaDistance, float displacement) 两个 API 来处理 OverScroll 行为, 需要在 onTouchEvent 中使用如上两个 API,再自定义对应行为.

如果你不喜欢这个动画的话,你也可以通过 xml 中设置 android:overScrollMode="never" 或者使用代码设置 recyclerview.setOverScrollMode(View.OVER_SCROLL_NEVER); 来屏蔽默认的滚动动画。

引入限制域概念(官方翻译: 限制性应用待机模式存储分区)

Android 12 引入了 APP 限制域, 限制域不但会定义应用的优先级(是否容易被系统杀死),处于低限制域的应用还会被限制一些比较消耗系统资源的行为。而限制域优先级的划分,很大程度上取决于你应用的使用频率以及当前是否在被前台使用。

想获取 APP 当前的限制域可以使用 getAppStandbyBucket 方法得到,示范:

1
2
3
4
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
UsageStatsManager manager = (UsageStatsManager) getSystemService(USAGE_STATS_SERVICE);
if(manager != null) manager.getAppStandbyBucket();
}

如果需要测试 APP 在严格限制域的表现,谷歌也提供了相对应的命令来进行模拟:

1
adb shell am set-standby-bucket PACKAGE_NAME restricted

这个只需要做充分的测试即可,毕竟你也做不了什么,避免被限制的方法很简单,让用户停在你的APP界面以及更经常的使用你的 APP, 这应该是所有应用开发者一直在研究的问题吧。

Display.getRealSize() & Display.getRealMetrics() 废弃

继 Android 11 废弃了 Display.getSize()Display.getMetrics() 后,Android 12 上进一步废弃了 Display.getRealMetrics()Display.getRealSize(). 现在推荐使用 WindowMetrics, 并且谷歌提供了一个兼容到 Android 4.0 的 WindowManager 兼容库。 通常情况可以使用如下代码代替以前计算屏幕宽高,:

1
2
3
4
5
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
metrics = activity.getWindowManager().getCurrentWindowMetrics();
int width = metrics.getBounds().width();
int height = metrics.getBounds().height();
}

如果你的应用还在使用如上 API, 赶紧替换掉吧。

非可信触摸事件会被屏蔽

Android 12 开始,如果 APP 被其他UI遮挡覆盖或者在APP上绘制了其他UI,用户所有的触摸(touch)事件不再会传递下来了,你的 APP 将无法响应这些触摸事件, 当然APP被如下特殊情况的窗口遮挡, 触摸事件可以正常传递:

  1. 不可见的窗口,窗口都不见了,也挡不住用户去摸了。
  2. 完全透明的窗口,和上一条如出一辙,完全透明的衣服等于没穿。(alpha 值是 0)
  3. 部分半透明窗口,遮不遮的住取决于透明度,目前版本最大 opacity 值 0.8 以下,依然可以传递触摸事件。不过将来正式版本这个值可能被修改。
  4. 遮挡的 UI 本身由你的APP创建,且只在你的 APP 内显示交互时.
  5. 遮挡你APP的是可信的遮挡UI,包括但不限于如下:
  • 软键盘或其他系统输入
  • 系统助手悬浮窗
  • 使用了 TYPE_ACCESSIBILITY_OVERLAY 标志的窗口 …

这一条个人认为,也是谷歌爸爸对现应用动不动喜欢搞个系统级的悬浮窗的一种限制,对绝大多数应用应该影响不大。

除了上述问题外,还存在一些其他细节修改,就不一一赘述,感兴趣的亲们可以仔细阅读 通配性问题谷歌文档

适配优先级

作为一个技术,不聊 DeadLine 来谈优先级,简直就是耍流氓。 对于通配性问题,建议所有开发者在 Android 12 正式版本发布后第一时间进行测试,来确认是否会影响自家产品和业务。

对于全面更新适配 Android 12(TargetSdkVersion 升级到 31), 目前 Android 12 还在进行 Beta5 测试,但尚未完成完全兼容测试(Compatibility Test Suite),按照以往的速度,快则个把月,慢则几个月。Android 12 正式版本会与大家见面,而从发布到用户真正能使用也需要一段时间。 对于应用开发者,在Android 12正式版本发布后的任何时候,都可以开始考虑进行适配工作。 等到各大国产厂商开始推送 Android 12 时,再进行适配,可能就被友商卷下去了。 而对于游戏开发者,联运渠道SDK本身适配也需要一段时间,考虑到绝大多数渠道目前还只要求TargetSdkVersion 26, 只上联运的话,会有非常充裕的适配时间,等各大联运渠道通知即可,估摸着2023年吧。 如果发海外 GooglePlay, 通常新系统出来后半年内需要进行适配,之后再过小半年,GooglePlay 就要开始强制要求适配了, 发海外 GooglePlay 也是越快适配越好的行情。 如果贵司纯靠买量投放与自有流量导入,那花一上午简单在 Android 12 上测试一下功能,啥时候高兴啥时候适配。

Android 12 已知问题

最后,聊聊谷歌目前 Android 12 的几个高频已知问题,如果你遇上了,不要慌,谷歌已经知道有这些问题了,反正你不需要去纠结。大概率(看谷歌心情)正式版本会被修复的。

  1. 通过 设置->账户密码添加账户时,系统会黑屏.
  2. 下滑通知栏,有时通知栏无法正常显示通知. 190269314 锁屏时有时无法正常显示通知 189173895
  3. 连着 USB 调试时,使用谷歌地图或者视频播放软件,Android 系统会崩溃. 189515336
  4. 使用虚拟键盘时,如果有通知进来,虚拟键盘会被自动最小化,且划掉通知前,无法正常使用键盘. 193920125

如果想详细了解 Android 12 的已知问题,请参阅 developer.android.com/about/versi…

总结

总体来说,对绝大多数产品, Android 12 的更新并不会像 Android 11 与 Android 10 复杂,且目前 Android 12 正式版本还未推出,部分内容各大手机硬件厂商也在与谷歌商讨,所以,对于绝大多数应用开发者,我的建议是,Android 12 发布后,针对文章提到的点进行一次兼容性测试就可以了。

Android UI组件——ViewPager

viewPager

官方解释:以可滑动的格式显示视图或 Fragment。 如果可能,请改用 viewpager2。

不难看出,这货已经被淘汰了,之后基本都是使用viewpager2,but,为了解了解基础,我们还是来说些废话。

ViewPager的简介和作用

ViewPager是android扩展包v4包中的类,这个类可以让用户左右切换当前的view 1)ViewPager类直接继承了ViewGroup类,所有它是一个容器类,可以在其中添加其他的view类。 2)ViewPager类需要一个PagerAdapter适配器类给它提供数据。 3)ViewPager经常和Fragment一起使用,并且提供了专门的FragmentPagerAdapter和FragmentStatePagerAdapter类供Fragment中的ViewPager使用。

(不知道Fragment是啥的直接叉出android组)

ViewPager的适配器

既然说它可以滑动显示View和Fragment?很像ListView有木有?所以,ViewPager也需要创建对应的PagerAdapter。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;

import androidx.viewpager.widget.PagerAdapter;

import java.util.List;

public class MyPagerAdapter extends PagerAdapter {

//用于存放视图
List<View> viewList;
//获得上下文,后续方便使用一点
Context context;

public MyPagerAdapter(List<View> viewList, Context context) {
this.viewList = viewList;
this.context = context;
}

//获得View的个数
@Override
public int getCount() {
return viewList.size();
}

/**
* 确认View与实例对象是否相互对应。ViewPager内部用于获取View对应的ItemInfo。
*
* @param view ViewPager显示的View内容
* @param object 在instantiateItem中提交给ViewPager进行保存的实例对象
* @return 是否相互对应
*/

@Override
public boolean isViewFromObject(View view, Object object) {
return view == object;
}

/**
* 为给定的位置创建相应的View。创建View之后,需要在该方法中自行添加到container中。
*
* @param container ViewPager本身
* @param position 给定的位置
* @return 提交给ViewPager进行保存的实例对象
*/
@Override
public Object instantiateItem(ViewGroup container, int position) {
container.addView(viewList.get(position));
return viewList.get(position);
}

/**
* 为给定的位置移除相应的View。
*
* @param container ViewPager本身
* @param position 给定的位置
* @param object 在instantiateItem中提交给ViewPager进行保存的实例对象
*/
@Override
public void destroyItem(ViewGroup container, int position, Object object) {
container.removeView(viewList.get(position));
}
}

然后在MainActivity的activity_main.xml文件中放入ViewPager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<androidx.viewpager.widget.ViewPager
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/view_pager"
/>

</LinearLayout>

创建一个或多个打算放入ViewPager的layout布局,用于ViewPager加载和显示

red.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:background="#c32136"
android:gravity="center"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:gravity="center"
android:text="red"
android:textSize="35sp"
/>
</LinearLayout>

orange.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:background="#ff7500"
android:gravity="center"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:gravity="center"
android:text="orange"
android:textSize="35sp"
/>
</LinearLayout>

green.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:background="#00bc12"
android:gravity="center"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:gravity="center"
android:text="green"
android:textSize="35sp"
/>
</LinearLayout>

blue.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:background="#44cef6"
android:gravity="center"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:gravity="center"
android:text="blue"
android:textSize="35sp"
/>
</LinearLayout>

然后就可以汇总了

MainActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;

import androidx.appcompat.app.AppCompatActivity;
import androidx.viewpager.widget.ViewPager;

import com.pj.viewpager.adapter.MyPagerAdapter;

import java.util.ArrayList;
import java.util.List;

public class MainActivity extends AppCompatActivity {

private ViewPager viewPager;
private List<View> viewList;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initView();
//使用上面自定义的PagerAdapter
MyPagerAdapter adapter = new MyPagerAdapter(viewList, MainActivity.this);
viewPager.setAdapter(adapter);
}

private void initView() {
viewPager = (ViewPager) findViewById(R.id.view_pager);

viewList = new ArrayList<>();
//加载4个xml布局文件并添加到list中
viewList.add(LayoutInflater.from(MainActivity.this).inflate(R.layout.red,null));
viewList.add(LayoutInflater.from(MainActivity.this).inflate(R.layout.orange,null));
viewList.add(LayoutInflater.from(MainActivity.this).inflate(R.layout.green,null));
viewList.add(LayoutInflater.from(MainActivity.this).inflate(R.layout.blue,null));
}
}

对MyPagerAdapter进行一些改动还可实现无限右拖动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;

import androidx.viewpager.widget.PagerAdapter;

import java.util.List;

public class MyPagerAdapter extends PagerAdapter {

//用于存放视图
List<View> viewList;
//获得上下文,后续方便使用一点
Context context;

public MyPagerAdapter(List<View> viewList, Context context) {
this.viewList = viewList;
this.context = context;
}

//获得View的个数
@Override
public int getCount() {
return Integer.MAX_VALUE;
}

/**
* 确认View与实例对象是否相互对应。ViewPager内部用于获取View对应的ItemInfo。
*
* @param view ViewPager显示的View内容
* @param object 在instantiateItem中提交给ViewPager进行保存的实例对象
* @return 是否相互对应
*/

@Override
public boolean isViewFromObject(View view, Object object) {
return view == object;
}

/**
* 为给定的位置创建相应的View。创建View之后,需要在该方法中自行添加到container中。
*
* @param container ViewPager本身
* @param position 给定的位置
* @return 提交给ViewPager进行保存的实例对象
*/
@Override
public Object instantiateItem(ViewGroup container, int position) {
// container.addView(viewList.get(position));
// return viewList.get(position);
try {
container.addView(viewList.get(position%viewList.size()));
}catch (Exception e){

}
return viewList.get(position%viewList.size());
}

/**
* 为给定的位置移除相应的View。
*
* @param container ViewPager本身
* @param position 给定的位置
* @param object 在instantiateItem中提交给ViewPager进行保存的实例对象
*/
@Override
public void destroyItem(ViewGroup container, int position, Object object) {
//避免滑动的时候出现动画卡顿
// container.removeView(viewList.get(position));
}
}

上面适配器是使用的View的形式,其实ViewPager主要是搭配Fragment一起使用,官方也给了另一个专门给Fragment适配的适配器

FragmentPagerAdapter

创建FragmentPagerAdapter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentPagerAdapter;

import java.util.List;

public class MyFragmentPagerAdapter extends FragmentPagerAdapter {

List<Fragment> fragments;

public MyFragmentPagerAdapter(FragmentManager fm, int behavior, List<Fragment> fragments) {
super(fm, behavior);
this.fragments = fragments;
}


@Override
public Fragment getItem(int position) {
return fragments.get(position);
}

//返回Fragment的个数
@Override
public int getCount() {
return fragments.size();
}


}

FragmentPagerAdapter 的构造方法中新增了一个 behavior 参数, 当参数设置为 FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT 时,就会使用 setMaxLifecycle() 来限制了 Fragment 的生命周期,只有当 Fragment 显示在屏幕中时才会执行 onResume(),这样就可以把加载数据的方法放在 onResume() 中从而实现懒加载。

关于Fragment+ViewPager懒加载,有兴趣的自己可以去深究

这样子ViewPager每次滑动的都是Fragment,对于每个Fragment也更加好管理了,代码也少了好多不是嘛(当然要新建Fragment类还有布局文件)

创建Fragment类以及布局

NewsListFragment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ListView;

import androidx.fragment.app.Fragment;

import com.pj.viewpager.R;
import com.pj.viewpager.adapter.MyNewsListAdapter;
import com.pj.viewpager.bean.NewsListJavaBean;

import java.util.ArrayList;
import java.util.List;

public class NewsListFragment extends Fragment {

private String title;
private ListView newsList;
private List<NewsListJavaBean> data;
private int ImageId;

public NewsListFragment(String title) {
this.title = title;
data = new ArrayList<>();
switch (title){
case "新闻":ImageId = R.drawable.news;break;
case "美食":ImageId = R.drawable.food;break;
case "运动":ImageId = R.drawable.football;break;
case "医疗":ImageId = R.drawable.doctor;break;
case "游戏":ImageId = R.drawable.game;break;
}
for (int i = 0; i < 10; i++) {
data.add(new NewsListJavaBean(ImageId,title,"这是一条"+title+"列表项"));
}
}

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
// Inflate the layout for this fragment
View view = inflater.inflate(R.layout.fragment_news_list, container, false);
initView(view);
MyNewsListAdapter adapter = new MyNewsListAdapter(data);
newsList.setAdapter(adapter);
return view;
}

private void initView(View view) {
newsList = (ListView) view.findViewById(R.id.news_list);
}
}

fragment_news_list.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragment.NewsListFragment"
android:orientation="vertical"
android:padding="5dp">

<ListView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:dividerHeight="5dp"
android:divider="@null"
android:id="@+id/news_list"
/>

</LinearLayout>
创建用于存放数据的NewsListJavaBean类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class NewsListJavaBean {
private int ImageId;
private String title;
private String content;

public NewsListJavaBean(int imageId, String title, String content) {
ImageId = imageId;
this.title = title;
this.content = content;
}

public NewsListJavaBean() {
}

public int getImageId() {
return ImageId;
}

public void setImageId(int imageId) {
ImageId = imageId;
}

public String getTitle() {
return title;
}

public void setTitle(String title) {
this.title = title;
}

public String getContent() {
return content;
}

public void setContent(String content) {
this.content = content;
}
}

创建为NewsListFragment布局中ListView的适配器

MyNewsListAdapter.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import android.annotation.SuppressLint;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;

import com.pj.viewpager.R;
import com.pj.viewpager.bean.NewsListJavaBean;

import java.util.List;

public class MyNewsListAdapter extends BaseAdapter {

List<NewsListJavaBean> data;
private ImageView newsListItemImg;
private TextView newsListItemTitle;
private TextView newsListItemContent;

public MyNewsListAdapter(List<NewsListJavaBean> data) {
this.data = data;
}

@Override
public int getCount() {
return data.size();
}

@Override
public Object getItem(int i) {
return data.get(i);
}

@Override
public long getItemId(int i) {
return i;
}

@Override
public View getView(int i, View view, ViewGroup viewGroup) {
@SuppressLint("ViewHolder") View v = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.news_list_item, null);
initView(v);
NewsListJavaBean bean = data.get(i);
newsListItemImg.setImageResource(bean.getImageId());
newsListItemTitle.setText(bean.getTitle());
newsListItemContent.setText(bean.getContent());
return v;
}

private void initView(View v) {
newsListItemImg = (ImageView) v.findViewById(R.id.news_list_item_img);
newsListItemTitle = (TextView) v.findViewById(R.id.news_list_item_title);
newsListItemContent = (TextView) v.findViewById(R.id.news_list_item_content);
}
}

创建适配器中的 R.layout.news_list_item 布局资源文件(ListView中的item布局文件)

news_list_item.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="100dp"
android:minHeight="100dp"
android:orientation="horizontal"
android:padding="10dp">
<ImageView
android:layout_width="100dp"
android:layout_height="80dp"
android:scaleType="centerCrop"
android:id="@+id/news_list_item_img"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:layout_marginLeft="10dp"
>
<TextView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:textSize="18sp"
android:id="@+id/news_list_item_title"
android:lines="1"
android:ellipsize="end"
/>
<TextView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:textSize="15sp"
android:id="@+id/news_list_item_content"
android:lines="1"
android:ellipsize="end"
/>
</LinearLayout>
</LinearLayout>
创建FragmentAndViewPager.java 的Activity类和布局进行汇总

activity_fragment_and_view_pager.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".FragmentAndViewPager">

<!-- ViewPager视图容器-->
<androidx.viewpager.widget.ViewPager
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/fragment_and_view_pager"
/>

</LinearLayout>

FragmentAndViewPager.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import android.os.Bundle;

import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentPagerAdapter;
import androidx.viewpager.widget.ViewPager;

import com.pj.viewpager.adapter.MyFragmentPagerAdapter;
import com.pj.viewpager.fragment.NewsListFragment;

import java.util.ArrayList;
import java.util.List;

public class FragmentAndViewPager extends AppCompatActivity {

private ViewPager fragmentAndViewPager;
private List<Fragment> fragments;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_fragment_and_view_pager);
initView();
//自定义的MyFragmentPagerAdapter 继承于 FragmentPagerAdapter
MyFragmentPagerAdapter adapter = new MyFragmentPagerAdapter(getSupportFragmentManager(), FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT, fragments);
fragmentAndViewPager.setAdapter(adapter);
}

private void initView() {
fragmentAndViewPager = (ViewPager) findViewById(R.id.fragment_and_view_pager);
fragments = new ArrayList<>();
fragments.add(new NewsListFragment("新闻"));
fragments.add(new NewsListFragment("美食"));
fragments.add(new NewsListFragment("运动"));
fragments.add(new NewsListFragment("医疗"));
fragments.add(new NewsListFragment("游戏"));
}
}
ViewPager+Fragment+TabLayout

关于TabLayout: blog.csdn.net/csdnxia/art… 自己去看吧(滑稽)

现改FragmentAndViewPager的布局代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".FragmentAndViewPager">

//利用TabLayout来显示ViewPager的标题,标明当前在哪一页
<com.google.android.material.tabs.TabLayout
android:layout_width="match_parent"
android:layout_height="60dp"
android:id="@+id/tab"
/>

<androidx.viewpager.widget.ViewPager
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/fragment_and_view_pager"
/>

</LinearLayout>
然后更改MyFragmentPagerAdapter的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentPagerAdapter;

import java.util.List;

public class MyFragmentPagerAdapter extends FragmentPagerAdapter {

List<Fragment> fragments;
String[] title; //加一个用于存储标题的数组或列表

//使用构造方法将title传过来
public MyFragmentPagerAdapter(FragmentManager fm, int behavior, List<Fragment> fragments,String[] title) {
super(fm, behavior);
this.fragments = fragments;
this.title = title;
}

@Override
public Fragment getItem(int position) {
return fragments.get(position);
}

@Override
public int getCount() {
return fragments.size();
}

//重写getPageTitle方法 返回值为title[position]
@Override
public CharSequence getPageTitle(int position) {
return title[position];
}
}

然后更改FragmentAndViewPager中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import android.os.Bundle;

import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentPagerAdapter;
import androidx.viewpager.widget.ViewPager;

import com.google.android.material.tabs.TabLayout;
import com.pj.viewpager.adapter.MyFragmentPagerAdapter;
import com.pj.viewpager.fragment.NewsListFragment;

import java.util.ArrayList;
import java.util.List;

public class FragmentAndViewPager extends AppCompatActivity {

private ViewPager fragmentAndViewPager;
private List<Fragment> fragments;
private TabLayout tab;
private String[] title = {"新闻","美食","运动","医疗","游戏"};

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_fragment_and_view_pager);
initView();
MyFragmentPagerAdapter adapter = new MyFragmentPagerAdapter(getSupportFragmentManager(), FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT, fragments,title);
fragmentAndViewPager.setAdapter(adapter);
tab.setupWithViewPager(fragmentAndViewPager);
}

private void initView() {
fragmentAndViewPager = (ViewPager) findViewById(R.id.fragment_and_view_pager);
fragments = new ArrayList<>();
fragments.add(new NewsListFragment(title[0]));
fragments.add(new NewsListFragment(title[1]));
fragments.add(new NewsListFragment(title[2]));
fragments.add(new NewsListFragment(title[3]));
fragments.add(new NewsListFragment(title[4]));
tab = (TabLayout) findViewById(R.id.tab);
}
}
关于滑动监听:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
}

@Override
public void onPageSelected(int position) {
Toast.makeText(MainActivity.this, "这是第"+(position+1)+"个页面", Toast.LENGTH_SHORT).show();
}

@Override
public void onPageScrollStateChanged(int state) {

}
});

1.onPageScrolled(int position,float positionOffset, int positionOffsetPixels):这个方法会在屏幕滚动过程中不断被调用。 有三个参数,第一个position,这个参数要特别注意一下。当用手指滑动时,如果手指按在页面上不动,position和当前页面index是一致的;如果手指向左拖动(相应页面向右翻动),这时候position大部分时间和当前页面是一致的,只有翻页成功的情况下最后一次调用才会变为目标页面;如果手指向右拖动(相应页面向左翻动),这时候position大部分时间和目标页面是一致的,只有翻页不成功的情况下最后一次调用才会变为原页面。 当直接设置setCurrentItem翻页时,如果是相邻的情况(比如现在是第二个页面,跳到第一或者第三个页面),如果页面向右翻动,大部分时间是和当前页面是一致的,只有最后才变成目标页面;如果向左翻动,position和目标页面是一致的。这和用手指拖动页面翻动是基本一致的。 如果不是相邻的情况,比如我从第一个页面跳到第三个页面,position先是0,然后逐步变成1,然后逐步变成2;我从第三个页面跳到第一个页面,position先是1,然后逐步变成0,并没有出现为2的情况。 positionOffset是当前页面滑动比例,如果页面向右翻动,这个值不断变大,最后在趋近1的情况后突变为0。如果页面向左翻动,这个值不断变小,最后变为0。 positionOffsetPixels是当前页面滑动像素,变化情况和positionOffset一致。

2.onPageSelected(int position):这个方法有一个参数position,代表哪个页面被选中。当用手指滑动翻页的时候,如果翻动成功了(滑动的距离够长),手指抬起来就会立即执行这个方法,position就是当前滑动到的页面。如果直接setCurrentItem翻页,那position就和setCurrentItem的参数一致,这种情况在onPageScrolled执行方法前就会立即执行。

3.onPageScrollStateChanged(int state):这个方法在手指操作屏幕的时候发生变化。有三个值:0(END),1(PRESS) , 2(UP) 。 当用手指滑动翻页时,手指按下去的时候会触发这个方法,state值为1,手指抬起时,如果发生了滑动(即使很小),这个值会变为2,然后最后变为0 。总共执行这个方法三次。一种特殊情况是手指按下去以后一点滑动也没有发生,这个时候只会调用这个方法两次,state值分别是1,0 。当setCurrentItem翻页时,会执行这个方法两次,state值分别为2 , 0

一文全面了解Android单元测试

前言

==》完整项目单元测试学习案例

众所周知,一个好的项目需要不断地打造,而一些有效的测试则是加速这一过程的利器。本篇博文将带你了解并逐步深入Android单元测试。

什么是单元测试?


单元测试就是针对类中的某一个方法进行验证是否正确的过程,单元就是指 独立的粒子,在Android和Java中大都是指方法。

为什么要进行单元测试?


使用单元测试可以提高开发效率,当项目随着迭代越来越大时,每一次编译、运行、打包、调试需要耗费的时间会随之上升,因此,使用单元测试可以不需这一步骤就可以对单个方法进行功能或逻辑测试。 同时,为了能测试每一个细分功能模块,需要将其相关代码抽成相应的方法封装起来,这也在一定程度上改善了代码的设计。因为是单个方法的测试,所以能更快地定位到bug。

单元测试case需要对这段业务逻辑进行验证。在验证的过程中,开发人员可以 深度了解业务流程,同时新人来了看一下项目单元测试就知道 哪个逻辑跑了多少函数,需要注意哪些边界——是的,单元测试做的好和文档一样 具备业务指导能力。

Android测试的分类


Android测试主要分为三个方面:

  • 1)、单元测试(Junit4、Mockito、PowerMockito、Robolectric)
  • 2)、UI测试(Espresso、UI Automator)
  • 3)、压力测试(Monkey)

一、单元测试之基础Junit4


什么是Junit4?


Junit4是事实上的Java标准测试库,并且它是JUnit框架有史以来的最大改进,其主要目标便是利用Java5的Annotation特性简化测试用例的编写。

开始使用Junit4进行单元测试


1.Android Studio已经自动集成了Junit4测试框架,如下

1
2
3
4
dependencies {
...
testImplementation 'junit:junit:4.12'
}

2.Junit4框架使用时涉及到的重要注解如下

1
2
3
4
5
6
7
8
9
10
@Test 指明这是一个测试方法 (@Test注解可以接受2个参数,一个是预期错误
expected,一个是超时时间timeout,
格式如 @Test(expected = IndexOutOfBoundsException.class),
@Test(timeout = 1000)
@Before 在所有测试方法之前执行
@After 在所有测试方法之后执行
@BeforeClass 在该类的所有测试方法和@Before方法之前执
行 (修饰的方法必须是静态的)@AfterClass 在该类的所有测试方法和@After
方法之后执行(修饰的方法必须是静态的)
@Ignore 忽略此单元测试

此外,很多时候,因为某些原因(比如正式代码还没有实现等),我们可能想让JUnit忽略某些方法,让它在跑所有测试方法的时候不要跑这个测试方法。要达到这个目的也很简单,只需要在要被忽略的 测试方法前面加上@Ignore 就可以了

3.主要的测试方法——断言

1
2
assertEquals(expected, actual) 判断2个值是否相等,相等则测试通过。
assertEquals(expected, actual, tolerance) tolerance 偏差值

注意:上面的每一个方法,都有一个重载的方法,可以加一个String类型的参数,表示如果验证失败的话,将 用这个字符串作为失败的结果报告

4.自定义Junit Rule——实现TestRule接口并重写apply方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class JsonChaoRule implements TestRule {

@Override
public Statement apply(final Statement base, final Description description) {
Statement repeatStatement = new Statement() {
@Override
public void evaluate() throws Throwable {
//测试前的初始化工作
//执行测试方法
base.evaluate();
//测试后的释放资源等工作
}
};
return repeatStatement;
}
}

然后在想要的测试类中使用@Rule注解声明使用JsonChaoRule即可(注意 被@Rule注解的变量必须是final的):

1
2
@Rule
public final JsonChaoRule repeatRule = new JsonChaoRule();

5.开始上手,使用Junit4进行单元测试

  • 1.编写测试类。
  • 2.鼠标右键点击测试类,选择选择Go To->Test (或者使用快捷键Ctrl+Shift+T,此快捷键可 以在方法和测试方法之间来回切换)在Test/java/项目 测试文件夹/下自动生成测试模板。
  • 3.使用断言(assertEqual、assertEqualArrayEquals等等)进行单元测试。
  • 4.右键点击测试类,Run编写好的测试类。

6.使用Android Studio自带的Gradle脚本自动化单元测试

点击Android Studio中的 Gradle projects 下的 app/Tasks/verification/test 即可同时测试module下所有的测试类(案例),并在 module下的build/reports/tests/下 生成对应的 index.html测试报告

7.对Junit4的总结:

  • 优点:速度快,支持代码覆盖率等代码质量的检测工具,
  • 缺点:无法单独对Android UI,一些类进行操作,与原生JAVA有一些差异。

可能涉及到的额外的概念:

打桩方法:使方法简单快速地返回一个有效的结果。

测试驱动开发:编写测试,实现功能使测试通过,然后不断地使用这种方式实现功能的快速迭代开发。

二、单元测试之基础Mockito

什么是Mockito?

Mockito 是美味的 Java 单元测试 Mock 框架,mock可以模拟各种各样的对象,从而代替真正的对象做出希望的响应。

开始使用Mockito进行单元测试

1.在build.gradle里面添加Mcokito的依赖

1
testImplementation 'org.mockito:mockito-core:2.7.1'

2.使用mock()方法模拟对象

1
Person mPerson = mock(Person.class);

能量补充站(-vov-)

在JUnit框架下,case(即每一个测试点,带@Test注解的那个函数)也是个函数,直接调用这个函数就不是case,和case是无关的,两者并不会相互影响,可以直接调用以减少重复代码。 单元测试不应该对某一个条件过度耦合,因此,需要用mock解除耦合, 直接mock出网络请求得到的数据,单独验证页面对数据的响应。

3.验证方法的调用,指定方法的返回值,或者执行特定的动作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
when(iMathUtils.sum(1, 1)).thenReturn(2);
doReturn(3).when(iMathUtils).sum(1,1);
//给方法设置桩可以设置多次,只会返回最后一次设置的值
doReturn(2).when(iMathUtils).sum(1,1);

//验证方法调用次数
//方法调用1次
Mockito.when(mockValidator.verifyPassword("xiaochuang_is_handsome")).thenReturn(true);
//方法调用3次
Mockito.when(mockValidator.verifyPassword("xiaochuang_is_handsome")
, Mockito.times(3).thenReturn(true);

//verify方法用于验证“模仿对象”的互动或验证发生的某些行为
verify(mPerson, atLeast(2)).getAge();

//参数匹配器,用于匹配特定的参数
any()
contains()
argThat()
when(mPerson.eat(any(String.class))).thenReturn("米饭");

//除了mock()外,spy()也可以模拟对象,spy与mock的
//唯一区别就是默认行为不一样:spy对象的方法默认调用
//真实的逻辑,mock对象的方法默认什么都不做,或直接
//返回默认值
//如果要保留原来对象的功能,而仅仅修改一个或几个
//方法的返回值,可以采用spy方法,无参构造的类初始
//化也使用spy方法
Person mPerson = spy(Person.class);

//检查入参的mocks是否有任何未经验证的交互
verifyNoMoreInteractions(iMathUtils);

4.使用Mockito后的思考

简单的测试会使整体的代码更简单,更可读、更可维护。如果你 不能把测试写的很简单,那么请在测试时重构你的代码

  • 优点:丰富强大的方式验证“模仿对象”的互动或验证发生的某些行为
  • 缺点:Mockito框架不支持mock匿名类、final类、static方法、private方法。

虽然,static方法可以使用wrapper静态类的方式实现mockito的单元测试,但是,毕竟过于繁琐,因此,PowerMockito由此而来。

三、拯救Mockito于水深火热的PowerMockito

什么是PowerMockito?

PowerMockito是一个扩展了Mockito的具有更强大功能的单元测试框架,它支持mock匿名类、final类、static方法、private方法

开始PowerMockito之旅

1.在build.gradle里面添加Mcokito的依赖

1
2
testImplementation 'org.powermock:powermock-module-junit4:1.6.5'
testImplementation 'org.powermock:powermock-api-mockito:1.6.5'

2.用PowerMockito来模拟对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//使用PowerMock须加注解@PrepareForTest和@RunWith(PowerMockRunner.class)(@PrepareForTest()里写的
// 是对应方法所在的类 ,mockito支持的方法使用PowerMock的形式实现时,可以不加这两个注解)
@PrepareForTest(T.class)
@RunWith(PowerMockRunner.class)

//mock含静态方法或字段的类
PowerMockito.mockStatic(Banana.class);

//Powermock提供了一个Whitebox的class,可以方便的绕开权限限制,可以get/set private属性,实现注入。
//也可以调用private方法。也可以处理static的属性/方法,根据不同需求选择不同参数的方法即可。
修改类里面静态字段的值
Whitebox.setInternalState(Banana.class, "COLOR", "蓝色");

//调用类中的真实方法
PowerMockito.when(banana.getBananaInfo()).thenCallRealMethod();

//验证私有方法是否被调用
PowerMockito.verifyPrivate(banana, times(1)).invoke("flavor");

//忽略调用私有方法
PowerMockito.suppress(PowerMockito.method(Banana.class, "flavor"));

//修改私有变量
MemberModifier.field(Banana.class, "fruit").set(banana, "西瓜");

//使用PowerMockito mock出来的对象可以直接调用final方法
Banana banana = PowerMockito.mock(Banana.class);

//whenNew 方法的意思是之后 new 这个对象时,返回某个被 Mock 的对象而不是让真的 new
//新的对象。如果构造方法有参数,可以在withNoArguments方法中传入。
PowerMockito.whenNew(Banana.class).withNoArguments().thenReturn(banana);

3.使用PowerMockRule来代替@RunWith(PowerMockRunner.class)的方式,需要多添加以下依赖:

1
2
testImplementation "org.powermock:powermock-module-junit4-rule:1.7.4"
testImplementation "org.powermock:powermock-classloading-xstream:1.7.4"

使用示例如下:

1
2
@Rule
public PowerMockRule mPowerMockRule = new PowerMockRule();

4.使用Parameterized来进行参数化测试:

通过注解@Parameterized.parameters提供一系列数据给构造器中的构造参数 或给 被注解@Parameterized.parameter注解的public全局变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
RunWith(Parameterized.class)
public class ParameterizedTest {

private int num;
private boolean truth;

public ParameterizedTest(int num, boolean truth) {
this.num = num;
this.truth = truth;
}

//被此注解注解的方法将把返回的列表数据中的元素对应注入到测试类
//的构造函数ParameterizedTest(int num, boolean truth)中
@Parameterized.Parameters
public static Collection providerTruth() {
return Arrays.asList(new Object[][]{
{0, true},
{1, false},
{2, true},
{3, false},
{4, true},
{5, false}
});
}

// //也可不使用构造函数注入的方式,使用注解注入public变量的方式
// @Parameterized.Parameter
// public int num;
// //value = 1指定括号里的第二个Boolean值
// @Parameterized.Parameter(value = 1)
// public boolean truth;

@Test
public void printTest() {
Assert.assertEquals(truth, print(num));
System.out.println(num);
}

private boolean print(int num) {
return num % 2 == 0;
}

}

四、能在Java单元测试里面执行Android代码的Robolectric

什么是Robolectric?

Robolectric通过 一套能运行在JVM上的Android代码,解决了在Java单元测试中很难进行Android单元测试的痛点。

进入Roboletric的领地


1.在build.gradle里面添加Robolectric的依赖

1
2
3
4
5
6
//Robolectric核心
testImplementation "org.robolectric:robolectric:3.8"
//支持support-v4
testImplementation 'org.robolectric:shadows-support-v4:3.4-rc2'
//支持Multidex功能
testImplementation "org.robolectric:shadows-multidex:3.+"

2.Robolectric常用用法

首先给指定的测试类上面进行配置

1
2
3
@RunWith(RobolectricTestRunner.class)
//目前Robolectric最高支持sdk版本为23。
@Config(constants = BuildConfig.class, sdk = 23)

下面是一些常用用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//当Robolectric.setupActivity()方法返回的时候,
//默认会调用Activity的onCreate()、onStart()、onResume()
mTestActivity = Robolectric.setupActivity(TestActivity.class);

//获取TestActivity对应的影子类,从而能获取其相应的动作或行为
ShadowActivity shadowActivity = Shadows.shadowOf(mTestActivity);
Intent intent = shadowActivity.getNextStartedActivity();

//使用ShadowToast类获取展示toast时相应的动作或行为
Toast latestToast = ShadowToast.getLatestToast();
Assert.assertNull(latestToast);
//直接通过ShadowToast简单工厂类获取Toast中的文本
Assert.assertEquals("hahaha", ShadowToast.getTextOfLatestToast());

//使用ShadowAlertDialog类获取展示AlertDialog时相应的
//动作或行为(暂时只支持app包下的,不支持v7。。。)
latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
AlertDialog latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
Assert.assertNull(latestAlertDialog);

//使用RuntimeEnvironment.application可以获取到
//Application,方便我们使用。比如访问资源文件。
Application application = RuntimeEnvironment.application;
String appName = application.getString(R.string.app_name);
Assert.assertEquals("WanAndroid", appName);

//也可以直接通过ShadowApplication获取application
ShadowApplication application = ShadowApplication.getInstance();
Assert.assertNotNull(application.hasReceiverForIntent(intent));

自定义Shadow类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Implements(Person.class)
public class ShadowPerson {

@Implementation
public String getName() {
return "AndroidUT";
}

}

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class,
sdk = 23,
shadows = {ShadowPerson.class})

Person person = new Person();
//实际上调用的是ShadowPerson的方法,输出JsonChao
Log.d("test", person.getName());

ShadowPerson shadowPerson = Shadow.extract(person);
//测试通过
Assert.assertEquals("JsonChao", shadowPerson.getName());

}

注意:异步测试出现一些问题(比如改变一些编码习惯,比如回调函数不能写成匿名内部类对象,需要定义一个全局变量,并破坏其封装性,即提供一个get方法,供UT调用),解决方案 使用Mockito来结合进行测试,将异步转为同步

3.Robolectric的优缺点

  • 优点:支持大部分Android平台依赖类底层的引用与模拟。
  • 缺点:异步测试有些问题,需要结合一些框架来配合完成更多功能。

五、单元测试覆盖率报告生成之jacoco

什么是Jacoco

Jacoco的全称为Java Code Coverage(Java代码覆盖率),可以 生成java的单元测试代码覆盖率报告

加入Jacoco到你的单元测试大家族

在应用Module下加入jacoco.gradle自定义脚本,app.gradle apply from它,同步,即可看到在app的Task下生成了Report目录,Report目录 下生成了JacocoTestReport任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
apply plugin: 'jacoco'

jacoco {
toolVersion = "0.7.7.201606060606" //指定jacoco的版本
reportsDir = file("$buildDir/JacocoReport") //指定jacoco生成报告的文件夹
}

//依赖于testDebugUnitTest任务
task jacocoTestReport(type: JacocoReport, dependsOn: 'testDebugUnitTest') {
group = "reporting" //指定task的分组
reports {
xml.enabled = true //开启xml报告
html.enabled = true //开启html报告
}

def debugTree = fileTree(dir: "${buildDir}/intermediates/classes/debug",
includes: ["**/*Presenter.*"],
excludes: ["*.*"])//指定类文件夹、包含类的规则及排除类的规则,
//这里我们生成所有Presenter类的测试报告
def mainSrc = "${project.projectDir}/src/main/java" //指定源码目录

sourceDirectories = files([mainSrc])
classDirectories = files([debugTree])
executionData = files("${buildDir}/jacoco/testDebugUnitTest.exec")//指定报告数据的路径
}

在Gradle构建板块 Gradle.projects 下的 app/Task/verification 下,其中 testDebugUnitTest 构建任务会生成单元测试结果报告,包 含xml及html 格式,分别对应 test-results和reports 文件夹;jacocoTestReport任务会生成单元测试覆盖率报告,结果存放在jacoco和JacocoReport文件夹。

生成的JacocoReport文件夹下的index.html即对应的单元测试覆盖率报告,用浏览器打开后,可以看到覆盖情况被不同的颜色标识出来,其中 绿色表示代码被单元测试覆盖到,黄色表示部分覆盖,红色则表示完全没有覆盖到

六、单元测试的流程

要验证程序正确性,必然要给出所有可能的条件(极限编程),并验证其行为或结果,才算是100%覆盖条件。实际项目中,验证 一般条件边界条件 就OK了。

在实际项目中, 单元测试对象与页面是一对一的,并不建议跨页面,这样的单元测试耦合太大,维护困难。 需要写完后,看覆盖率,找出单元测试中没有覆盖到的函数分支条件等,然后继续补充单元测试case列表,并在单元测试工程代码中补上case。 直到规划的 页面中所有逻辑的重要分支、边界条件都被覆盖,该项目的单元测试结束。

建议(-ovo-)~

可以从公司项目 小规模使用,形成 自己的单元测试风格 后,就可更大范围地推广了。

公钟号同名,欢迎关注,关注后回复 Framework,我将分享给你一份我这两年持续总结、细化、沉淀出来的 Framework 体系化精品面试题,里面很多的核心题答案在面试的压力下,经过了反复的校正与升华,含金量极高~

linux安装prometheus

一、什么是Prometheus

Prometheus(普罗米修斯)是由SoundCloud开发的开源监控报警系统和时序列数据库(TSDB)。Prometheus使用Go语言开发,是Google BorgMon监控系统的开源版本。
2012年成为社区开源项目,拥有非常活跃的开发人员和用户社区。
2016年由Google发起Linux基金会旗下的原生云基金会(Cloud Native Computing Foundation), 将Prometheus纳入其下第二大开源项目。
Prometheus目前在开源社区相当活跃。
Prometheus和Heapster(Heapster是K8S的一个子项目,用于获取集群的性能数据。)相比功能更完善、更全面。Prometheus性能也足够支撑上万台规模的集群。

二、Prometheus的特点

  • 多维度数据模型。
  • 灵活的查询语言。
  • 不依赖分布式存储,单个服务器节点是自主的。
  • 通过基于HTTP的pull方式采集时序数据。
  • 可以通过中间网关进行时序列数据推送。
  • 通过服务发现或者静态配置来发现目标服务对象。
  • 支持多种多样的图表和界面展示,比如Grafana等。### 创建log文件
    ps.png

三、 基本原理

Prometheus的基本原理是通过HTTP协议周期性抓取被监控组件的状态,任意组件只要提供对应的HTTP接口就可以接入监控。不需要任何SDK或者其他的集成过程。这样做非常适合做虚拟化环境监控系统,比如VM、Docker、Kubernetes等。输出被监控组件信息的HTTP接口被叫做exporter 。目前互联网公司常用的组件大部分都有exporter可以直接使用,比如Varnish、Haproxy、Nginx、MySQL、Linux系统信息(包括磁盘、内存、CPU、网络等等)。

四、服务过程

  • Prometheus Daemon负责定时去目标上抓取metrics(指标)数据,每个抓取目标需要暴露一个http服务的接口给它定时抓取。Prometheus支持通过配置文件、文本文件、Zookeeper、Consul、DNS SRV Lookup等方式指定抓取目标。Prometheus采用PULL的方式进行监控,即服务器可以直接通过目标PULL数据或者间接地通过中间网关来Push数据。
  • Prometheus在本地存储抓取的所有数据,并通过一定规则进行清理和整理数据,并把得到的结果存储到新的时间序列中。
  • Prometheus通过PromQL和其他API可视化地展示收集的数据。
  • Prometheus支持很多方式的图表可视化,例如Grafana、自带的Promdash以及自身提供的模版引擎等等。Prometheus还提供HTTP API的查询方式,自定义所需要的输出。
  • PushGateway支持Client主动推送metrics到PushGateway,而Prometheus只是定时去Gateway上抓取数据。
  • Alertmanager是独立于Prometheus的一个组件,可以支持Prometheus的查询语句,提供十分灵活的报警方式。

五、三大套件

  • Server 主要负责数据采集和存储,提供PromQL查询语言的支持。
  • Alertmanager 警告管理器,用来进行报警。
  • Push Gateway 支持临时性Job主动推送指标的中间网关。

六、下载Prometheus

1、官网下载地址:https://prometheus.io/download/

2、直接在linux服务器上wget方式下载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 新建目录
mkdir -p /data/prometheus/

// 进入目标目录
cd /data/prometheus/

// 下载
wget -c https://github.com/prometheus/prometheus/releases/download/v2.28.1/prometheus-2.28.1.linux-amd64.tar.gz

// 解压
tar -vxzf prometheus-2.28.1.linux-amd64.tar.gz

// 移动到安装目录
mv prometheus-2.28.1.linux-amd64 /usr/local/prometheus

// 进入目录
cd /usr/local/prometheus

七、将Prometheus配置为系统服务

1、进入systemd目录

cd /usr/lib/systemd/system

2、创建文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

vim prometheus.service

# 添加如下内容
[Unit]
Description=https://prometheus.io

[Service]
Restart=on-failure
ExecStart=/root/prometheus/prometheus --config.file=/root/prometheus/prometheus.yml

[Install]
WantedBy=multi-user.target


3、生效系统文件

systemctl daemon-reload

4、启动服务停止服务

1
2
3
4
# 启动
systemctl start prometheus.service
# 停止
systemctl stop prometheus.service

八、启动Prometheus

1
2
3
4
5
6
7
8
9
10
11

# 进入解压后的文件夹
cd /data/prometheus/prometheus-2.28.1.linux-amd64
# 前台启动
./prometheus --config.file=prometheus.yml
# 后台启动prometheus,并且重定向输入日志到当前目录的prometheus.out
nohup ./prometheus --config.file=prometheus.yml >> /data/prometheus/prometheus-2.28.1.linux-amd64/prometheus.out 2>&1 &

//Mark 修改端口
./prometheus --config.file=prometheus.yml --web.listen-address=:9091

九、访问prometheus

http:192.168.0.102:9090,默认端口为 9090
ps.png

十、配置截图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 此片段指定的是prometheus的全局配置,比如采集间隔,抓取超时时间等。如果有内部单独设定,会覆盖这个参数。
global:
scrape_interval: 15s # 抓取间隔,默认继承global值(默认15s 全局每次数据收集的间隔)
evaluation_interval: 15s # 评估规则间隔(规则扫描时间间隔是15秒,默认不填写是1分钟)
# scrape_timeout 抓取超时时间,默认继承global值。

# 此片段指定告警配置,这里会设定alertmanager这个报警插件。
alerting:
alertmanagers:
- static_configs:
- targets: ['localhost:9093']

# 此片段指定报警规则文件,按照设定参数进行扫描加载,用于自定义报警规则,其报警媒介和route路由由alertmanager插件实现。
rule_files:
- "rules/*.yml"
# - "first_rules.yml"
# - "second_rules.yml"

# 此片段指定抓取配置。配置数据源,包含分组job_name以及具体target。又分为静态配置和服务发现。
scrape_configs:
- job_name: 'prometheus'

# metrics_path defaults to '/metrics' # 监控项访问的url路径
# scheme defaults to 'http'.

static_configs:
- targets: ['localhost:9090'] # 监控目标访问地址

- job_name: 'suyuan' # 任务目标名,可以理解成分组,每个分组包含具体的target组员。
scrape_interval: 30s # 这里如果单独设定的话,会覆盖global设定的参数,拉取时间间隔为30s
static_configs:
- targets: ['39.99.254.135:30101']
labels:
instance: 'bigdata(39.99.254.135:9000)'

- targets: ['39.99.254.135:30102']
labels:
instance: 'web(39.99.254.135:9999)'

- targets: ['39.99.254.135:30103']
labels:
instance: 'user(39.99.254.135:9003)'

告警配置 targets 和抓取配置 targets 的 localhost 最好替换成服务器的 ip,job_name 的名字对应的就是 grafana dashboard 的 job 的名称,instance 如果不通过 labels 单独指定,默认取的是 targets 的值,建议单独指定别名。

上述为静态规则,没有设置自动发现。这种情况下增加主机需要自行修改规则,通过 supervisor reload 对应任务,也是缺点:每次静态规则添加都要重启prometheus服务,不利于运维自动化。

prometheus支持服务发现(也是运维最佳实践经常采用的):
文件服务发现
基于文件的服务发现方式不需要依赖其他平台与第三方服务,用户只需将 要新的target信息以yaml或json文件格式添加到target文件中 ,prometheus会定期从指定文件中读取target信息并更新
好处:
(1)不需要一个一个的手工去添加到主配置文件,只需要提交到要加载目录里边的json或yaml文件就可以了;
(2)方便维护,且不需要每次都重启prometheus服务端。

配置更新

在更新完Prometheus的配置文件后,我们需要更新我们的配置到程序内存里,这里的更新方式有两种,第一种简单粗暴,就是重启Prometheus,第二种是动态更新的方式。如何实现动态的更新Prometheus配置。

步骤
第一步:首先要保证启动Prometheus的时候带上启动参数:–web.enable-lifecycle。

1
prometheus --config.file=/usr/local/etc/prometheus.yml --web.enable-lifecycle

第二步:去更新我们的Prometheus配置。

第三步:更新完配置后,我们可以通过POS请求的方式,动态更新配置。

1
curl -v --request POST 'http://localhost:9090/-/reload'

原理

Prometheus在web模块中,注册了一个handler。

1
2
3
4
5
6
if o.EnableLifecycle {
router.Post("/-/quit", h.quit)
router.Put("/-/quit", h.quit)
router.Post("/-/reload", h.reload) // reload配置
router.Put("/-/reload", h.reload)
}

通过h.reload这个handler方法实现:这个handler就是往一个channle中发送一个信号。

1
2
3
4
5
6
7
func (h *Handler) reload(w http.ResponseWriter, r *http.Request) {
rc := make(chan error)
h.reloadCh <- rc // 发送一个信号到channe了中
if err := <-rc; err != nil {
http.Error(w, fmt.Sprintf("failed to reload config: %s", err), http.StatusInternalServerError)
}
}

在main函数中会去监听这个channel,只要有监听到信号,就会做配置的reload,重新将新配置加载到内存中。

1
2
3
4
5
6
7
case rc := <-webHandler.Reload():
if err := reloadConfig(cfg.configFile, cfg.enableExpandExternalLabels, cfg.tsdb.EnableExemplarStorage, logger, noStepSubqueryInterval, reloaders...); err != nil {
level.Error(logger).Log("msg", "Error reloading config", "err", err)
rc <- err
} else {
rc <- nil
}

Android App 迁移 KMM 实践

Aengus

2023-04-03 4,078

KMM Beta推出已经有一段时间了,但是写这篇文章期间因为各种原因耽搁了,导致拖了好久才完成,可能会有部分内容与当下最新情况不同

KMM(Kotlin Multiplatform Mobile)最近推出了Beta版本,Jetpack也官宣了将对KMM进行支持,并推出了DataStore与Collection两个库的预览版本,正好手头有个Android项目,于是打算尝尝鲜。

首先介绍一下Android App的整体技术方案。整体架构遵循了MAD推荐架构,如下图所示,将App分为UI层、网域层和数据层。UI层中,业务逻辑均交给了ViewModel实现,比较通用的逻辑则下沉到了网域层;数据层中,较为复杂的Repository又依赖了DataSource,部分比较简单的Repository则直接使用了API访问:

App目前主要用到的技术选型为:UI界面Compose,界面导航Navigation,数据库Room,网络请求Retrofit,依赖注入Hilt,JSON库Moshi;此外在所有地方均使用协程与Flow;

得益于协程已经提供了KMM支持,并且数据库、网络请求、依赖注入、JSON序列化均已有可用的工具,因此理论上来讲除了UI界面相关的元素,网域层和数据层均可下沉到common层以达到双端复用的目的。对于数据库,有SQLDelight,网络请求有Ktor,而依赖注入和序列化则分别有Koin和KotlinX Serialization。下面介绍一下具体迁移过程。

工程迁移

为了防止原本的Gradle版本、库版本不对齐导致难以排查的问题,创建了一个全新的KMM项目,然后再将原先的代码库搬到Android Module下,然后再进行下沉,这样做可以保证KMM项目均使用官方推荐的Gradle脚本等,但需要手工搬代码、改包名等,工作量比较大,推荐的方式还是将KMM以Module的形式集成进来。

依赖注入

原来是Hilt,改为Koin,考虑兼容成本,Android现有代码仍使用Hilt,Koin使用十分简单,查看 官方文档 即可,此处不再赘述。由于两套依赖注入库共存,因此需要一些桥接手段,这里介绍一下桥接过程中遇到的问题:

  1. 已经下沉到common层并且使用Koin注入的类,如果Hilt仍然需要注入,可以声明 Provides,其实现从Koin中获取:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Module
    @InstallIn(SingletonComponent::class)
    object KoinAdapterModule {
    @Provides
    @Singleton
    fun provideAuthTokenRepository(): AuthTokenRepository {
    return KoinJavaComponent.get(AuthTokenRepository::class.java)
    }
    }
  2. Android工程Module内的类依赖Android实现,但是又想把这部分移到common层复用。解决:抽离接口,在common层的Koin Module中注入空实现或者基础实现,然后在Android application中重新注入实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    @HiltAndroidApp
    class MyApplication : Application() {

    @Inject lateinit var interfaceBImpl: InterfaceBAndroidImpl

    @Inject lateinit var userServiceImpl: AndroidUserService

    override fun onCreate() {
    super.onCreate()

    startKoin {
    androidLogger()
    androidContext(this@MyApplication)
    // appModule() 在common层中
    modules(appModule() + provideOverrideModule())
    }
    }

    private fun provideOverrideModule(): Module = module {
    factory<InterfaceA> {
    InterfaceAAndroidImpl()
    }
    factory<InterfaceB> {
    interfaceBImpl
    }
    single<UserService> {
    userServiceImpl
    }
    }
    }

    // AndroidUserService.kt
    @Singleton
    class AndroidUserService @Inject constructor(
    // AuthTokenRepository由Koin提供注入
    private val authTokenRepository: AuthTokenRepository
    ) : UserService {
    // ...
    }

    在上面,我们重新注入了三个对象。重新注入的情况比较复杂,可能会有时序问题,我们分别分析:

    1. 重新注入的对象 InterfaceAAndroidImpl 不依赖Hilt,此时没有任何问题;

    2. 重新注入的对象 interfaceBImpl 依赖Hilt,但是不依赖Koin提供的实例,此时代码上面的代码也没有问题;

    3. 重新注入的对象 userServiceImpl 不仅依赖Hilt,还依赖Koin提供的其他实例,此时需要将 startKoin 放在 super.onCreate() 之前,保证Koin在Hilt之前完成注入;我们知道Hilt通过生成代码的方式完成注入,也就是在 super.onCreate() 内进行注入,因此待Hilt注入之后,我们再次将Koin重新注入。此时代码变为:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      class MyApplication : Application() {
      override fun onCreate() {
      // 1. Koin注入基础实现
      val koin = startKoin {
      androidLogger()
      androidContext(this@MyApplication)
      modules(appModule())
      }
      // 2. Hilt在生成的类中完成@Inject对象的注入
      super.onCreate()
      // 3. 重新对Koin注入真正实现
      koin.modules(listOf(provideOverrideModule()))
      }
      }

      上述的方式依赖Koin的默认配置,即 allowOverride=truecreatedAtStart=false

    4. 重新注入的对象不仅依赖Hilt,还依赖Koin提供的其他重新注入的实例,那只能将此对象以及此对象依赖的其他实例全部交由Koin进行注入,需要进行较大的改动;

同时也吐槽一下在iOS中使用Koin注入,需要将所有用到的类在Kotlin中包一层,而不是像在Android中可以直接 get(),不清楚iOS是否有更方便的注入方式,但是目前的注入方式实在有些繁琐。

网络库

网络库由Retrofit迁移至Ktor,相应的JSON库也由Moshi迁移为Kotlin Serialization,JSON库迁移比较简单,主要就是注解换一下。网络库迁移则稍微麻烦一些:

首先是依赖部分,Android和iOS均需要添加平台依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:2.1.2")
implementation("io.ktor:ktor-client-content-negotiation:2.1.2")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.1.2")
}
}

val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-android:2.1.2")
}
}

val iosMain by creating {
dependencies {
implementation("io.ktor:ktor-client-darwin:2.1.2")
}
}

Ktor使用 HttpClient 进行网络请求,在 commonMain 中添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 此处使用Koin注入
val commonModule = module {
factory {
HttpClient(provideEngineFactory()) {
defaultRequest {
url("https://example.com")
// 添加默认Header参数
header(HttpHeaders.ContentType, ContentType.Application.Json)
}

install(ContentNegotiation) {
json(Json {
// 是否使用Kotlin字段的默认值
encodeDefaults = true
prettyPrint = true
isLenient = true
// 是否忽略未知的JSON key
ignoreUnknownKeys = true
})
}
}
}
}

expect fun provideEngineFactory(): HttpClientEngineFactory<HttpClientEngineConfig>

然后分别在 androidMainiosMain 目录下实现 provideEngineFactory 方法:

1
2
3
4
5
6
7
// androidMain
actual fun provideEngineFactory(): HttpClientEngineFactory<HttpClientEngineConfig>
= Android

// iosMain
actual fun provideEngineFactory(): HttpClientEngineFactory<HttpClientEngineConfig>
= Darwin

在数据层,拿到 HttpClient 实例后,直接调用 get/post/... 方法即可,使用 body<T> 方法获取结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
httpClient
.put("/api/v1/article") {
url {
// 在URL后方添加Path参数
appendPathSegments("20230101")
}
// 添加Query参数,即 url?from=web
parameter("from", "web")
// 设置Header
header("token", token)
// 设置Request Body
setBody(param)
}
.body<Response<Data>()

数据库

数据库使用 SQLDelight 框架。其依赖分别为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
val commonMain by getting {
dependencies {
implementation("com.squareup.sqldelight:runtime:1.5.4")
}
}

val androidMain by getting {
dependencies {
implementation("com.squareup.sqldelight:android-driver:1.5.4")
}
}

val iosMain by creating {
dependencies {
implementation("com.squareup.sqldelight:native-driver:1.5.4")
}
}

接着在分别在根目录下的 build.gradle.kts 和common层Module下的 build.gradle.kts 中添加以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 根目录 build.gradle.kts
buildscript {
dependencies {
classpath("com.squareup.sqldelight:gradle-plugin:1.5.4")
}
}

// shared/build.gradle.kts
plugins {
// ...
id("com.squareup.sqldelight")
}

sqldelight {
database("AppDatabase") {
packageName = "com.example.app.database"
}
}

SQLDelight将根据上面的配置,生成 com.example.app.database.AppDatabase 类及其 Schema,之后可以调用此类进行数据库相关操作。SQLDelight默认读取sqldelight目录下的 sq 文件生成代码,也可以通过 sourceFolders = listof("customFolder") 进行配置,这里我们不进行设置。在 src/commonMain/sqldelight 目录下 创建 com.example.app.database 包,然后在其中创建 Article.sq 文件,文件第一行通常为创建表语句,后面跟随CRUD语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CREATE TABLE article(
article_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL
);

findAll:
SELECT *
FROM article;

findById:
SELECT *
FROM article
WHERE article_id = :articleId;

insertArticle:
INSERT INTO article(article_id, title, content)
VALUES (?, ?, ?);

insertArticleObject:
INSERT INTO article(article_id, title, content)
VALUES ?;

上面的文件将生成 ArticleQueries.kt 文件,为了访问此API,添加以下代码创建数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// commonMain中
val databaseModule = module {
single {
AppDatabase(createDriver(
scope = this,
schema = AppDatabase.Schema,
dbName = "app_database.db"
))
}
}

expect fun createDriver(scope: Scope, schema: SqlDriver.Schema, dbName: String): SqlDriver

/// androidMain中
actual fun createDriver(scope: Scope, schema: SqlDriver.Schema, dbName: String): SqlDriver {
val context = scope.androidContext()
return AndroidSqliteDriver(schema, context, dbName)
}

/// iosMain中
actual fun createDriver(scope: Scope, schema: SqlDriver.Schema, dbName: String): SqlDriver {
return NativeSqliteDriver(schema, dbName)
}

之后我们便可以通过 AppDatabase 访问到 ArticleQueries

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class ArticleLocalDataSource(
database: AppDatabase
) {
private val articleQueries: ArticleQueries = database.articleQueries

fun findAll(): List<Article> {
return articleQueries.findAll().executeAsList()
}

fun findById(id: Int): Article? {
// :articleId 为命名参数,因此此处形参名变为articleId而不是article_id
return articleQueries.findById(articleId = id).executeAsOneOrNull()
}

fun insertArticle(id: Int, title: String, content: String) {
articleQueries.insertArticle(article_id = id, title = title, content = content)
}

fun insertArticles(articles: List<Article>) {
// 在一个事务中执行多个语句
articleQueries.transaction {
articles.forEach {
articleQueries.insertArticleObject(it)
}
}
}
}

SELECT 语句默认返回 data class,可以通过传入 mapper 来转换结果:

1
2
3
4
5
articleQueries.selectAll(
mapper = { articleId, title, content ->
ArticleTitle(articleId, title)
}
)

SQLDelight提供了协程扩展,通过添加依赖 com.squareup.sqldelight:coroutines-extensions:1.5.4 可以将结果转为 Flow

1
2
3
4
val articles: Flow<List<Article>> =
articleQueries.findAll()
.asFlow()
.mapToList()

注意:SQLDelight 2.0.0版本后包名及plugin id有所变化,具体查看官方文档

如果由于成本或其他原因,不打算迁移数据库相关内容,但仍想复用数据层,可以将 LocalDataSource 变为接口,common层Repository依赖接口,默认使用空实现,而在上层则使用平台相关数据库实现具体逻辑。需要注意业务中不能含有依赖本地数据库操作的block逻辑,否则可能导致难以排查的bug。

业务逻辑

这里说的业务逻辑主要指ViewModel相关的类,由于ViewModel为Android Jetpack库,无法直接下沉到common层中,目前有第三方提供了KMM库,如 KMM-ViewModelMOKO mvvm,其Android下的实现均是继承自Jetpack的ViewModel类,但两个库均无法使用Koin注入ViewModel(MOKO有相关 issue,但暂无进展),并且使用MOKO mvvm需要将Activity继承自 MvvmActivity,对项目侵入度比较高。

此处提供一个复用思路,将业务逻辑与ViewModel解耦。Android端ViewModel最大的意义是维持状态在配置发生变化时不丢失,而将业务逻辑不一定非要写在ViewModel的子类里,我们可以将业务逻辑单独提取在 Bloc 类中,在Koin中均使用 factory 提供实现,在Android中,ViewModel作为“Bloc 容器”,iOS中则可以直接使用 Koin#get 进行创建即可。将ViewModel作为容器则可以借助 retained 库,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/// commonMain
class ArticleBloc(
private val articleRepository: ArticleRepository
) {
val uiStateFlow: StateFlow<ArticleUiState> = ...

fun destroy() {
// cancel coroutine...
}
}
// Koin提供实现
val blocModule = module {
factory {
ArticleBloc(
articleRepository = get()
)
}
}
/// Android中使用
class ArticleFragment : Fragment() {
// 下面的代码也可以抽成更通用的扩展函数方便使用
private val articleBloc: ArticleBloc by retain { entry ->
val bloc = get<ArticleBloc>()
entry.onClearedListeners += OnClearedListener {
bloc.destroy()
}
bloc
}
}
/// iOS中使用
object BlocFactory : KoinComponent {
fun createArticleBloc(): ArticleBloc = get()
}

ViewModel作为容器相关文章:

和上述方案思路类似的也有现成的库 Kotlin Bloc,其提供了更严格的MVI、SAM风格架构,对于新项目来说可以尝试一下。

由于 Bloc 类与平台相关类解耦,因此原本ViewModel中直接使用的 SavedStateHandle 也无法直接依赖,此时可以将从 SavedStateHandle 获取的值作为参数传入 Bloc 类中,或者抽取接口, Bloc 类依赖接口,构造时将 SavedStateHandle 作为参数传到接口的实现类中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
interface ISavedStateHandle {
fun <T> getStateFlow(key: String, initialValue: T): StateFlow<T>
operator fun <T> set(key: String, value: T?)
operator fun <T> get(key: String): T?
}

val blocModule = module {
factory {
ArticleBloc(
savedStateHandle = it.get()
)
}
}

/// androidMain
class AndroidSavedStateHandle(
private val delegate: SavedStateHandle
) : ISavedStateHandle {

override fun <T> getStateFlow(key: String, initialValue: T): StateFlow<T> {
return delegate.getStateFlow(key, initialValue)
}

override fun <T> set(key: String, value: T?) {
delegate[key] = value
}

override fun <T> get(key: String): T? {
return delegate[key]
}
}

/// Android中使用
private val articleBloc: ArticleBloc by retain { entry ->
val bloc = get<ArticleBloc>(parametersOf(AndroidSavedStateHandle(entry.savedStateHandle)))
entry.onClearedListeners += OnClearedListener {
bloc.destroy()
}
bloc
}

对于一些平台特殊实现的函数,若没有相关的KMM库,可以手动实现,提供其接口,然后通过依赖注入库注入实现。

Swift调用及限制

Flow / Bloc

下沉后的 Bloc,在Swift中不能像在Android中直接 launch 协程然后 collect,Swift中通常通过 ObservableObject 实现数据UI绑定,这里结合之前看到的另外一个KMM项目 KMMNewsApp 介绍一种解决方案。

对于每个 Bloc,Swift中增加一个对应的包装类,此类的职责是监听 Bloc 中的Flow,并将其绑定到Swift中的State,其结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Foundatin
import Combine
import shared

class ArticleViewModel : ObservableObject {
private(set) var bloc: ArticleBloc

@Published private(set) var state: ArticleUiState

init(_ wrapped: ArticleBloc) {
bloc = wrapped
state = wrapped.uiStateFlow.value as! ArticleUiState
(wrapped.uiStateFlow.asPublisher() as AnyPublisher<ArticleUiState, Never>)
.receive(on: RunLoop.main)
.assign(to: &$state)
}
}

asPublisher 的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// FlowPublisher.swift

import Foundation
import Combine
import shared

public extension Kotlinx_coroutines_coreFlow {
func asPublisher<T: AnyObject>() -> AnyPublisher<T, Never> {
(FlowPublisher(flow: self) as FlowPublisher<T>).eraseToAnyPublisher()
}
}

struct FlowPublisher<T: Any> : Publisher {
public typealias Output = T
public typealias Failure = Never
private let flow: Kotlinx_coroutines_coreFlow

public init(flow: Kotlinx_coroutines_coreFlow) {
self.flow = flow
}

public func receive<S: Subscriber>(subscriber: S) where S.Input == T, S.Failure == Failure {
subscriber.receive(subscription: FlowSubscription(flow: flow, subscriber: subscriber))
}

final class FlowSubscription<S: Subscriber>: Subscription where S.Input == T, S.Failure == Failure {
private var subscriber: S?
private var job: Kotlinx_coroutines_coreJob?
private let flow: Kotlinx_coroutines_coreFlow
init(flow: Kotlinx_coroutines_coreFlow, subscriber: S) {
self.flow = flow
self.subscriber = subscriber
job = FlowExtensionsKt.subscribe(
flow,
onEach: { item in if let item = item as? T { _ = subscriber.receive(item) }},
onComplete: { subscriber.receive(completion: .finished) },
onThrow: { error in debugPrint(error) }
)
}

func cancel() {
subscriber = nil
job?.cancel(cause: nil)
}

func request(_ demand: Subscribers.Demand) {
}
}
}

FlowExtensionsKt 为Kotlin代码,只是对操作符进行包装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun Flow<*>.subscribe(
onEach: (item: Any) -> Unit,
onComplete: () -> Unit,
onThrow: (error: Throwable) -> Unit
): Job = this.subscribe(Dispatchers.Main, onEach, onComplete, onThrow)

fun Flow<*>.subscribe(
dispatcher: CoroutineDispatcher,
onEach: (item: Any) -> Unit,
onComplete: () -> Unit,
onThrow: (error: Throwable) -> Unit
): Job =
this.onEach { onEach(it as Any) }
.catch { onThrow(it) }
.onCompletion { onComplete() }
.launchIn(CoroutineScope(Job() + dispatcher))

然后在View中调用即可:

1
2
3
4
5
6
7
8
struct ArticleView : View {

@ObservedObject var viewModel: ArticleViewModel

var body: some View {
return Text(viewModel.state.title)
}
}

有些同学可能习惯使用 SharedFlow 来用作事件通信(Android官方推荐使用 StateFlow,但是此处不在我们的讨论范围内),如果使用上面我们提到的 ArticleViewModel 的方式可能会遇到问题,比如下面这种情况:

1
2
3
4
5
6
7
8
9
10
sealed class LoginMessage {
class UsernameEmpty : LoginMessage
class PasswordEmpty : LoginMessage
class WrongPassword : LoginMessage
}

class LoginBloc {
private val _messageFlow: MutableSharedFlow<LoginMessage> = MutableSharedFlow()
val messageFlow: SharedFlow<LoginMessage> = _messageFlow
}

因为 SharedFlow 并没有 value 变量,所以Swift中的变量的初始化就变成了问题,此时也不能将 AnyPublisher 的第一个泛型变为可空类型,否则会编译失败。对于这种情况,我们可以在Swift中实现接口作为初始值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import Foundatin
import Combine
import shared

class LoginViewModel: ObservableObject {
private(set) var bloc: LoginBloc

@Published private(set) var message: LoginMessage

init(_ wrapped: LoginBloc) {
bloc = wrapped
message = EmptyMessage()
(wrapped.messageFlow.asPublisher() as AnyPublisher<LoginMessage, Never>)
.receive(on: RunLoop.main)
.assign(to: &$state)
}
}

class EmptyMessage: LoginMessage

message 类型为 EmptyMessage 时则忽略。

上面提到的 Kotlin Bloc 库同样提供了 BlocObserver 类,其功能类似将 Bloc 包装为ViewModel类。

一些其他介绍在Swift中监听Kotlin Flow的文章:

第三方库: Koru

密封接口/类

Kotlin的sealed interface或sealed class,在Swift中访问需要将点 . 去掉,如

1
2
3
sealed interface State<out T> {
object Loading : State<Nothing>
}

在Swift中就变成了 StateLoading,并且单例需要调用 StateLoading.shared

Swift中调用类似上述的 sealed interface/class 还有一个问题,由于泛型限制,在Swift中无法将 StateLoading.shared 识别为任意 State 泛型的子类,而在Kotlin则可以:

1
2
3
4
// work in Kotlin
class PageState(
val loadingState: State<Unit> = State.Loading
)
1
2
3
4
5
// not work in Swift
struct PageState {
// Cannot assign value of type 'State<KotlinNothing>' to type 'State<KotlinUnit>'
var loadingState: State<KotlinUnit> = StateLoading.shared
}

对于这个问题,有以下几种可选方案:

  1. 假如某个类型的 State 使用比较多,可以创建一个单独的类在Swift中使用,如 object StateUnitLoading : State<Unit>()
  2. 使用 StateLoading.shared as Any as! State<KotlinUnit> 进行强转(暂时没有试过),具体可以查看 KT-55156 [KMM] How to use Covariance in Swift;
  3. 使用插件 MOKO KSwift 将类转为Swift中的枚举类型,详细查看 How to implement Swift-friendly API with Kotlin Multiplatform Mobile

枚举

Kotlin中声明的枚举,到了Swift中会变成小写开头,如果小写命中了Swift的关键字,则需要在后面加 _ 后缀,如:

1
2
3
4
enum class Visibility {
Private,
Group
}

对应到Swift中的调用则为 Visibility.private_Visibility.group

模块化

大部分Android App都可能会有多个Module,而在KMM中,假如一个类引用了另外一个Module中的类,并在Swift中由于某些原因需要类型转换时,可能会引起cast error。比如分别在 model Module中有一个类为 UiState,而在 shared Module中有一个类为 Greeting,两个类结构如下:

1
2
3
4
5
6
7
8
// UiState in model Module
data class UiState(
val title: String
)
// Greeting in shared Module
class Greeting {
val uiStateFlow: StateFlow<UiState> = MutableStateFlow(UiState(""))
}

假如在Swift中获取 Greeting.uiStateFlow.value,由于 StateFlow 被编译为OC后丢失了泛型信息,因此需要对 value 进行强转,此时就会报cast error:

Swift cast error

但如果将 UiState 也移到 shared Module中,问题就会消失。出现问题的原因是每个Kotlin Module都会被独立编译,因此 shared.UiState != model.UiState,目前官方还在跟进修复中,详细可以查看这两个issue KT-56420, KT-42247。这个问题也可以通过一些方式绕过,比如我们可以将强转类型修改为 ModelUiState

1
let state = Greeting().uiStateFlow.value as! ModelUiState

这样就可以正常运行,这是由于 ModelUiStateshared Module中的类,而 UiState 则是 model 中的类。

Swift Binding

Compose中, TextFiled 通过传入 value 参数以及回调 onValueChange 来进行数据UI之间的绑定,而在Swift中则是通过 Binding 结构体,通过添加 @State 即可将值变为 Binding 类型,如下:

1
2
3
4
5
6
7
8
9
10
11
struct InputView : View {
@State var text: String = ""

var body: some View {
return VStack {
TextField(text: $text, lable: {
Text("请输入")
})
}
}
}

如果UiState类字段为 var 可变(但不推荐这么做),虽然可以直接绑定到ViewModel中的字段让代码看似正常的跑起来,但是这直接打破了数据流的方向以及破坏了 Bloc 的封装,从而可能导致bug,因此不要这么做,此时推荐进行适当的冗余,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct InputView : View {
@ObservedObject var viewModel: InputViewModel

@State var text: String = ""

var body: some View {
return VStack {
TextField(text: $text, lable: {
Text("请输入")
}).onChange(of: text, perform: { newValue in
viewModel.bloc.updateText(text: newValue)
})
}
}
}

总结

作为一个比较简单的Android App,在迁移过程中仍遇到了不少问题,需要用一些tricky的手段或进行一些妥协,而且遇到的一些问题也很难第一时间确认是代码逻辑有问题还是KMM本身的问题,比较影响开发效率。目前KMM不建议在生产环境或大规模App中使用,或许作为“玩具”在新小App中尝鲜或者作为新技术学习可以一试。

怎样检测函数执行是否卡顿 ?

卡顿意味着我们的App发生了掉帧,被使用者所感知。 而导致App卡顿的原因很多:UI绘制慢、内存使用不当(内存抖动)等等情况都会导致程序出现卡顿,而这些卡顿又分为:可重现与不可重现。

可重现的卡顿

有一部分的卡顿是可本地复现的,对于这种容易重现的场景,一般我们在开发及体验测试阶段容易注意得到,而定位卡顿的根源,我们常用的方法是通过 TraceView、Systrace 工具来抓取卡顿过程中函数的执行情况(堆栈,耗时,调用次数等)。 通过 TraceView 的可视化界面,我们可以具体知道某个过程中的调用栈信息及各个函数的执行次数与耗时,能比较直观的找到严重耗时的函数,帮助我们快速解决卡顿问题。

目前 Traceview 已弃用。如果使用 Android Studio 3.2 或更高版本,则应改为使用 CPU Profiler

不可重现的卡顿

但往往大部分卡顿是很难及时发现的,不可重现的卡顿,经常出现在线上用户的真实使用过程中,这种卡顿往往跟机器性能,手机环境,甚至是操作偏好等因素息息相关。一般也是从用户反馈中得到,通常表述为“你们APP好卡”,我们很难在这种描述中,直接洞察到卡顿的根源,甚至有些连卡顿的场景都不知道,很难准确重现,所以这种卡顿容易让人摸不着头脑。 当然作为开发者,我更希望用户反馈的是,“某某函数耗时666ms,请解决它。” 那么面对这种卡顿,我们怎么办呢?

解决方案

造成卡顿的直接原因通常是,主线程执行繁重的UI绘制、大量的计算或IO等耗时操作。

业界有几种常见解决方案,都可以从一定程度上,帮助开发者快速定位到卡顿的堆栈,如 BlockCanary、LogMonitor 。这些方案的主要思想是,监控主线程执行耗时,当超过阈值时,dump出当前主线程的执行堆栈,通过堆栈分析找到卡顿原因。

从监控主线程的实现原理上,主要分为两种:

1、依赖主线程 Looper,监控每次 dispatchMessage 的执行耗时。(BlockCanary)

2、依赖 Choreographer 模块,监控相邻两次 Vsync 事件通知的时间差。(ArgusAPM、LogMonitor)

第一种方案,看下 Looper#loop 代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void loop() {
...
for (;;) {
...
// This must be in a local variable, in case a UI event sets the logger
Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
msg.target.dispatchMessage(msg);
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
...
}
}

主线程所有执行的任务都在 dispatchMessage 方法中派发执行完成,我们通过 setMessageLogging 的方式给主线程的 Looper 设置一个 Printer ,因为 dispatchMessage 执行前后都会打印对应信息,在执行前利用另外一条线程,通过 Thread#getStackTrace 接口,以轮询的方式获取主线程执行堆栈信息并记录起来,同时统计每次 dispatchMessage 方法执行耗时,当超出阈值时,将该次获取的堆栈进行分析上报,从而来捕捉卡顿信息,否则丢弃此次记录的堆栈信息。

第二种方案,利用系统 Choreographer 模块,向该模块注册一个 FrameCallback 监听对象,同时通过另外一条线程循环记录主线程堆栈信息,并在每次 Vsync 事件 doFrame 通知回来时,循环注册该监听对象,间接统计两次 Vsync 事件的时间间隔,当超出阈值时,取出记录的堆栈进行分析上报。

简单代码实现如下:

1
2
3
4
5
6
7
8
9
10
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
if(frameTimeNanos - mLastFrameNanos > 100) {
...
}
mLastFrameNanos = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
}
});

这两种方案,可以较方便的捕捉到卡顿的堆栈,但其最大的不足在于,无法获取到各个函数的执行耗时,对于稍微复杂一点的堆栈,很难找出可能耗时的函数,也就很难找到卡顿的原因。另外,通过其他线程循环获取主线程的堆栈,如果稍微处理不及时,很容易导致获取的堆栈有所偏移,不够准确,加上没有耗时信息,卡顿也就不好定位。

所以怎么更准确地捕捉卡顿堆栈,又能计算出各个函数执行耗时的方案。 而要计算函数的执行耗时,最关键的点在于如何对执行过程中的函数进行打点监控。

1、字节码插桩,修改字节码,在编译期修改所有 class 文件中的函数字节码,对所有函数前后进行打点插桩。

2、使用JVMTI监听函数进入与退出,JVMTI全称是Java Virtual Machine Tool Interface,Java虚拟机工具接口,在Android 8.0及以上可用,可以让我们监控与控制虚拟机的某种行为。

在应用启动时,默认打开 Trace 功能(Debug.startMethodTracing),应用内所有函数在执行前后将会经过alvik 上 dvmMethodTraceAdd 函数 或 art 上 Trace::LogMethodTraceEvent 函数, 通过hack手段代理该函数,在每个执行方法前后进行打点记录。

配置 Apache 服务器

目的:

  • 能够有一个测试的服务器,不是所有的特殊网络服务都能找到免费的!

为什么是 Apache

  1. 使用最广的 Web 服务器,IIS(微软 Intenet Infomation Server)
  2. Mac自带,只需要修改几个配置就可以,简单,快捷
  3. 有些特殊的服务器功能,Apache都能很好的支持
    例如:HTTP PUT/DELETE 操作,知识补充涉及的 HTTPS 服务

准备工作

  • 设置用户密码

配置

  1. 配置服务器的工作

    • Finder 中创建一个 Sites 的文件夹,直接创建在 /Users/apple(当前用户名)目录下
    • 修改配置文件中的两个路径,指向刚刚创建的文件夹
    • 拷贝一个文件
  2. 配置服务器注意事项

* 关闭中文输入法
* 命令和参数之间需要有`空格`
* 修改系统文件一定记住`sudo`,否则会没有权限
* 目录要在 `/Users/apple`(当前用户名)
  1. 配置服务器

提示:$开头的,可以拷贝,但是不要拷贝$

1
2
# 切换工作目录
$ cd /etc/apache2

常用命令

  • cd 切换目录
  • pwd 确认当前目录
  • ls 列表显示当前目录的内容
1
2
3
4
5
6
7
8
# *** 备份文件,以防不测,只需要执行一次就可以了
# 格式 cp (copy 的缩写) httpd.conf (源文件) httpd.conf.bak (目标文件)

$sudo cp httpd.conf httpd.conf.bak

# 提示:如果后续操作出现错误!可以使用以下命令,恢复备份过的 httpd.conf 文件

$ sudo cp httpd.conf.bak httpd.conf
  • 编辑 httpd.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# vim里面只能用键盘,不能用鼠标
# 用vim编辑httpd.conf
$ sudo vim httpd.conf

# 查找`DocumentRoot`
* /DocumentRoot

# 进入编辑模式
* i

# 修改`两处`引号中的路径

# 继续向下找到

Options FollowSymLinks Multiviews

# 加一个单词 `Indexes`,修改后的结果如下:

Options Indexes FollowSymLinks Multiviews

# 返回命令模式
* ESC

# 查找php
* /php

# 将光标移动到首行

* 0

# 删除行首注释 #

* x

# 保存并退出
* :wq

# 不保存退出!!!!!!!!!
* :q!
  • 拷贝php.ini文件
1
2
3
4
5
6
7
8
9
# 切换工作目录
$ cd /etc

# etc 目录有点类似于 windows/system32,存放配置文件的目录

$ sudo cp php.ini.default php.ini

# 重新启动apache服务器
$ sudo apachectl -k restart

如果提示以下错误是正常的:

1
2
httpd: Could not reliably determine the server's fully qualified domain name, using teacher.local for ServerName
httpd not running, trying to start

常见问题:

  • 如果点击 info.php 文件,出现下载,或者只是显示一小段文字

解决办法:

在终端中输入以下两个命令:

1
2
3
4
5
# 关闭 apache 服务器
$ sudo apachectl -k stop

# 重新再次启动 apache
$ sudo apachectl -k start
  • 每次启动计算机,Apache 服务器默认是不会自动启动的!

可以启动计算机之后,打开终端,输入以下命令:

1
2
3
4
5
# 启动 apache
$ sudo apachectl -k start

# 查询历史输入的命令
$ history
  • 最常见的问题

配置Apache常见错误.png

交换文件已经存在,直接按字母 “d”,可以删除交换文件!

提示:目前不要花一分钟去学习vim。

  • 执行脚本的时候,显示没有拒绝访问!大家用 NTFS 格式的 U 盘拷贝网络素材!可能会把文件本身的权限过滤掉!

以下是在终端中修改文件权限的指令!

1
2
3
$ ls -la
$ chmod 644 info.php(没有权限的文件名)
$ chmod 644 *.*
0%