0%


国外的疫情越来越严重,不排除有卷土重来的可能,今年突如其来的疫情影响着每一个人,就业形势也不容乐观,不管是企业还是个人都要共同面对这次生存考验,今年的毕业生也是非常的不容易,不管外界如何变化,持续强化自己的专业技能永远是立身之本,提升自己的核心竞争力永远是生存之道。最近学了一些自定义gradle插件相关的知识,开发了自动上传fir并发送钉钉消息的gradle插件,提升了个人和团队的工作效率,在此记录并分享。


由于公司当前的开发流程尚未用到Jenkins这类在线的自动化构建平台,开发流程仍然是开发者本地打包再上传fir托管平台并发送上传完成的消息到钉钉群里,再@相关的测试人员提醒可进行测试,这一系列流程化的操作显得过于繁琐。作为Android开发掌握groovy这门语言也是有必要的,毕竟这是Android studio的构建脚本语言且语法与java相似,完全可以利用构建脚本实现打包后自动上传到fir托管平台,再利用自定义钉钉机器人的官方api发送钉钉消息给相关的测试人员,解放双手,自动化这一系列的流程操作来提升工作效率,减少加班的可能,打包/上传/发消息这段时间喝杯咖啡放松一下不是更好吗?我决定把这个插件打包上传到JitPack分享给团队和网络上的其他人,因为帮更多的人提升工作效率确实是一件很cool的事情

实现自定义Gradle插件的三种方式

app的gradle文件中定义

  1. 直接修改app的gradle文件,继承gradle的Plugin类,定义一个自定义插件的实现类,重写apply方法完成插件逻辑
    1
    2
    3
    4
    5
    6
    7
    apply plugin: PluginImpl
    class PluginImpl implements Plugin<Project>{
    @override
    void apply(Project project){
    println("hello Plugin")
    }
    }
    优点:方便调试
    缺点:跟app工程代码混在一起,无法给其它项目使用

    buildSrc目录定义

    在工程目录下,新建buildSrc目录,在该目录下开发
    新建文件夹src/main/groovy/xxx/xxx/xxx,其中xxx/xxx/xxx是包名,在这个目录下自定义gradle插件类PluginImpl
    新建文件夹resource/META-INF/gradle-plugins,在这个目录下新建文件xxx.xxx.xxx.properties,声明插件实现类implementation-class=xxx.xxx.xxx.PluginImpl
    效果图
    优点:方便调试,与app工程分离
    缺点:无法给其它项目使用

新增插件lib定义

新建一个gradle插件的lib库,删除其它的所有文件,保持项目结构与buildSrc目录一样,在这个lib库下开发
优点:与app工程分离,可打包上传maven,在其它项目中引用并分享给其他人
缺点:调试稍微麻烦,需发布到本地仓库
以上三种方式最好的是第三种,与app工程分离,可发布到本地maven仓库下调试,也可以打包插件上传到私有的maven仓库或者公有maven仓库如JitPack分享给其他人

定义插件参数

定义fir配置参数

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
package com.demo.plugin

import javax.inject.Inject

public class FirExtension {
private String appName //app名称
private String iconPath //图标路径
private String token //fir设置中的上传token

@Inject
public FirExtension() {
}

String getAppName() {
return appName
}

void setAppName(String appName) {
this.appName = appName
}

String getIconPath() {
return iconPath
}

void setIconPath(String iconPath) {
this.iconPath = iconPath
}

String getToken() {
return token
}

void setToken(String token) {
this.token = token
}


@Override
public String toString() {
return "FirExtension{" +
"appName='" + appName + '\'' +
", iconPath='" + iconPath + '\'' +
", token='" + token + '\'' +
'}';
}
}

定义钉钉配置参数

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
package com.demo.plugin

import javax.inject.Inject

public class DingTalkExtension {
private String webHook // 钉钉自定义机器人的webhook
private String title // 消息标题
private String content // 内容
private boolean isAtAll // 是否@所有人
private List<String> atMobiles // 手机号列表,单独@某些人
@Inject
public DingTalkExtension() {
}

String getWebHook() {
return webHook
}

void setWebHook(String webHook) {
this.webHook = webHook
}

String getTitle() {
return title
}

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

String getContent() {
return content
}

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

boolean getIsAtAll() {
return isAtAll
}

void setIsAtAll(boolean isAtAll) {
this.isAtAll = isAtAll
}

List<String> getAtMobiles() {
return atMobiles
}

void setAtMobiles(List<String> atMobiles) {
this.atMobiles = atMobiles
}


@Override
public String toString() {
return "DingTalkExtension{" +
"webHook='" + webHook + '\'' +
", title='" + title + '\'' +
", content='" + content + '\'' +
", isAtAll=" + isAtAll +
", atMobiles=" + atMobiles +
'}';
}
}

定义插件参数

  • 结合上面两类参数配置
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    package com.demo.plugin

    import org.gradle.api.Action
    import org.gradle.api.model.ObjectFactory

    class UploadApkPluginExtension {
    FirExtension firExtension
    DingTalkExtension dingTalkExtension
    public UploadApkPluginExtension(ObjectFactory objectFactory) {
    firExtension = objectFactory.newInstance(FirExtension.class)
    dingTalkExtension = objectFactory.newInstance(DingTalkExtension.class)
    }

    public void fir(Action<FirExtension> action) {
    action.execute(firExtension)
    }

    public void dingTalk(Action<DingTalkExtension> action){
    action.execute(dingTalkExtension)
    }

    }

    插件逻辑

    groovy中我们仍然可以使用okhttp,Gson这类Android中常用的开源框架来实现网络请求,Json序列化/反序列化相关的逻辑,封装网络请求工具类,最后在自定义Plugin中调用这个工具类完成自动化

    引入开源库

    修改插件lib中的gradle文件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    apply plugin: 'java-library'
    apply plugin: 'groovy'
    apply plugin: 'maven'
    dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation gradleApi()//gradle sdk
    implementation localGroovy()//groovy sdk
    implementation 'com.squareup.okhttp3:okhttp:4.7.2'
    implementation 'com.google.code.gson:gson:2.8.6'
    }

    repositories {
    mavenCentral()
    jcenter()
    }

    网络请求工具类

    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
    package com.demo.plugin

    import com.google.gson.Gson
    import okhttp3.*

    import java.util.concurrent.TimeUnit

    public class OkHttpUtil{
    OkHttpClient okHttpClient
    Gson gson
    DingTalk dingTalk // 序列化钉钉消息工具类
    public OkHttpUtil(){
    okHttpClient = new OkHttpClient.Builder()
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(60, TimeUnit.SECONDS).build()
    gson = new Gson()
    dingTalk = new DingTalk()
    }
    // 获取fir上传证书
    BundleApp getCert(String appPackage, String apiTokenFir){

    FormBody.Builder build = new FormBody.Builder()
    build.add("bundle_id", appPackage)
    build.add("api_token", apiTokenFir)
    build.add("type", "android")
    Request request = new Request.Builder().url("http://api.bq04.com/apps").post(build.build()).build()
    Response response = okHttpClient.newCall(request).execute()
    String result = response.body.string()
    return gson.fromJson(result, BundleApp.class)
    }

    // 上传apk到fir
    String uploadApk(String apkPath,String key,String token,String appName,String appVersion,String appBuild,String fileName,String upload_url) {
    RequestBody fileBody = RequestBody.create(MediaType.parse("application/octet-stream"), new File(apkPath))
    MultipartBody body = new MultipartBody.Builder()
    .setType(MediaType.parse("multipart/form-data"))
    .addFormDataPart("key", key)
    .addFormDataPart("token", token)
    .addFormDataPart("x:name", appName)
    .addFormDataPart("x:version", appVersion)
    .addFormDataPart("x:build", appBuild)
    .addFormDataPart("file", fileName, fileBody)
    .build()
    Request requestApk = new Request.Builder().url(upload_url).post(body).build()

    Response responseApk = okHttpClient.newCall(requestApk).execute()
    return responseApk.body.string()
    }

    // 上传icon到fir
    String uploadIcon(String apkIconPath,String keyIcon,String tokenIcon,String upload_urlIcon){
    RequestBody fileBodyIcon = RequestBody.create(MediaType.parse("application/octet-stream"),new File(apkIconPath))
    MultipartBody bodyIcon = new MultipartBody.Builder()
    .setType(MediaType.parse("multipart/form-data"))
    .addFormDataPart("key", keyIcon)
    .addFormDataPart("token", tokenIcon)
    .addFormDataPart("file", "icon.png", fileBodyIcon)
    .build()
    Request requestIcon = new Request.Builder().url(upload_urlIcon).post(bodyIcon).build()
    Response responseIcon = okHttpClient.newCall(requestIcon).execute()
    return responseIcon.body.string()
    }

    ApkInfo getApkUrl(String appPackage, String apiTokenFir) {
    // 获取成功连接
    String queryurl =
    "http://api.bq04.com/apps/latest/$appPackage?api_token=$apiTokenFir&type=android"
    Request requestUrl = new Request.Builder().url(queryurl).get().build()
    Response responseUrl = okHttpClient.newCall(requestUrl).execute()
    String result = responseUrl.body.string()
    return gson.fromJson(result,ApkInfo.class)
    }

    // 发送钉钉链接消息
    void sendDingTalkLink(String text,String title,String url,String webHook){
    RequestBody linkBody = FormBody.create(MediaType.parse("application/json; charset=utf-8")
    , dingTalk.createLinkMsg(text,title,url))
    Request linkDingTalk = new Request.Builder().url(webHook)
    .post(linkBody).build()
    Response responseLink = okHttpClient.newCall(linkDingTalk).execute()
    String result = responseLink.body.string()
    println("已发送钉钉链接:$result")
    }

    // 发送钉钉文本消息
    void sendDingTalkMsg(String text,String webHook,boolean isAtAll,List<String> atMobiles){
    RequestBody textBody = FormBody.create(MediaType.parse("application/json; charset=utf-8")
    , dingTalk.createTextMsg(text,atMobiles,isAtAll))
    Request textDingTalk = new Request.Builder().url(webHook)
    .post(textBody).build()
    Response responseText = okHttpClient.newCall(textDingTalk).execute()
    String result = responseText.body.string()
    println("已发送钉钉消息:$result")
    }
    }

    序列化工具类

    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
    package com.demo.plugin
    import com.google.gson.Gson

    public class DingTalk {
    Gson gson
    public DingTalk(){
    gson = new Gson()
    }
    // 定义钉钉链接消息的bean类
    def class LinkMsg{
    String msgtype
    Link link

    public LinkMsg(String msgtype,Link link){
    this.msgtype = msgtype
    this.link = link
    }
    }

    def class Link{
    String title
    String text
    String messageUrl

    public Link(String title,String text,String messageUrl){
    this.title = title
    this.text = text
    this.messageUrl = messageUrl
    }
    }

    // 定义钉钉文本消息的bean类
    def class TextMsg{
    String msgtype
    Text text
    At at
    public TextMsg(String msgtype,Text text,At at){
    this.msgtype = msgtype
    this.text = text
    this.at = at
    }
    }

    def class At{
    List<String> atMobiles = new ArrayList<>()
    boolean isAtAll = true
    public At(List<String> atMobiles,boolean isAtAll){
    this.atMobiles = atMobiles
    if (!atMobiles.isEmpty()){
    this.isAtAll = false
    }
    else{
    this.isAtAll = isAtAll
    }
    }
    }

    def class Text{
    String content

    public Text(String content){
    this.content = content
    }
    }

    /**
    * 构建一个钉钉链接消息
    * @param text String
    * @param title String
    * @param url String
    * @return String
    */
    String createLinkMsg(String text ,String title,String url) {
    Link link = new Link(title,text,url)
    LinkMsg linkMsg = new LinkMsg("link",link)
    return gson.toJson(linkMsg)
    }

    /**
    * 构建一个钉钉文本消息
    * @param msgtype String
    * @param content String
    * @param text String
    * @return String
    */
    String createTextMsg(String content,List<String> atMobiles,boolean isAtAll){
    Text text = new Text(content)
    At at = new At(atMobiles,isAtAll)
    TextMsg textMsg = new TextMsg("text", text, at )
    return gson.toJson(textMsg)
    }
    }

反序列化bean类定义

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
package com.demo.plugin;

public class ApkInfo {
/**
* name : fir.im
* version : 1.0
* changelog : 更新日志
* versionShort : 1.0.5
* build : 6
* installUrl : http://download.bq04.com/v2/app/install/xxxxxxxxxxxxxxxxxxxx?download_token=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
* install_url : http://download.bq04.com/v2/app/install/xxxxxxxxxxxxxxxx?download_token=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
* update_url : http://fir.im/fir
* binary : {"fsize":6446245}
*/
private String name;
private String version;
private String changelog;
private String versionShort;
private String build;
private String installUrl;
private String install_url;
private String update_url;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public String getVersion() {
return version;
}

public void setVersion(String version) {
this.version = version;
}

public String getChangelog() {
return changelog;
}

public void setChangelog(String changelog) {
this.changelog = changelog;
}

public String getVersionShort() {
return versionShort;
}

public void setVersionShort(String versionShort) {
this.versionShort = versionShort;
}

public String getBuild() {
return build;
}

public void setBuild(String build) {
this.build = build;
}

public String getInstallUrl() {
return installUrl;
}

public void setInstallUrl(String installUrl) {
this.installUrl = installUrl;
}

public String getInstall_url() {
return install_url;
}

public void setInstall_url(String install_url) {
this.install_url = install_url;
}

public String getUpdate_url() {
return update_url;
}

public void setUpdate_url(String update_url) {
this.update_url = update_url;
}
}
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
package com.demo.plugin;

public class BundleApp {

/**
* id : 5592ceb6537069f2a8000000
* type : ios
* short : yk37
* cert : {"icon":{"key":"xxxxx","token":"xxxxxx","upload_url":"http://upload.qiniu.com"},"binary":{"key":"xxxxx","token":"xxxxxx","upload_url":"http://upload.qiniu.com"}}
*/


private CertBean cert;

public CertBean getCert() {
return cert;
}

public void setCert(CertBean cert) {
this.cert = cert;
}

public static class CertBean {
/**
* icon : {"key":"xxxxx","token":"xxxxxx","upload_url":"http://upload.qiniu.com"}
* binary : {"key":"xxxxx","token":"xxxxxx","upload_url":"http://upload.qiniu.com"}
*/

private IconBean icon;
private BinaryBean binary;

public IconBean getIcon() {
return icon;
}

public void setIcon(IconBean icon) {
this.icon = icon;
}

public BinaryBean getBinary() {
return binary;
}

public void setBinary(BinaryBean binary) {
this.binary = binary;
}

public static class IconBean {
/**
* key : xxxxx
* token : xxxxxx
* upload_url : http://upload.qiniu.com
*/

private String key;
private String token;
private String upload_url;

public String getKey() {
return key;
}

public void setKey(String key) {
this.key = key;
}

public String getToken() {
return token;
}

public void setToken(String token) {
this.token = token;
}

public String getUpload_url() {
return upload_url;
}

public void setUpload_url(String upload_url) {
this.upload_url = upload_url;
}
}

public static class BinaryBean {
/**
* key : xxxxx
* token : xxxxxx
* upload_url : http://upload.qiniu.com
*/

private String key;
private String token;
private String upload_url;

public String getKey() {
return key;
}

public void setKey(String key) {
this.key = key;
}

public String getToken() {
return token;
}

public void setToken(String token) {
this.token = token;
}

public String getUpload_url() {
return upload_url;
}

public void setUpload_url(String upload_url) {
this.upload_url = upload_url;
}
}
}
}

自定义Plugin

  1. 继承gradle的Plugin类,重写apply方法中实现插件逻辑
  2. 在assemble任务后执行插件相关的逻辑
  • 兼容Debug/Release两种打包方式和各个flavor下的打包任务,在这些任务打包完成后执行插件逻辑,采用”assemble” + variant.name.capitalize() +”Fir”的打包命令灵活配置
  • 如渠道名为googlePlay下打debug/release包则打包任务名为assembleGooglePlayDebug/assembleGooglePlayRelease
    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
    package com.demo.plugin
    import org.gradle.api.Plugin
    import org.gradle.api.Project
    import org.gradle.api.Task

    class PluginImpl implements Plugin<Project> {
    UploadApkPluginExtension extension

    @Override
    void apply(Project project) {
    extension = project.extensions.create('uploadApk', UploadApkPluginExtension.class, project.getObjects())
    if (project.android.hasProperty("applicationVariants")) {
    project.android.applicationVariants.all { variant ->
    // 定义gradle任务名称
    Task uploadFir = project.task("assemble${variant.name.capitalize()}Fir").doLast {
    println("开始上传Fir")
    def (String appPackage, String apiTokenFir, String apkPath, String fileName, String appName, String appVersion, String appBuild, String apkIconPath) = getParams(project, variant)
    OkHttpUtil okHttpUtil = new OkHttpUtil()
    BundleApp bundleApp = okHttpUtil.getCert(appPackage,apiTokenFir)
    println("获取凭证信息成功")
    BundleApp.CertBean certBean = bundleApp.getCert()

    // 上传apk
    println("上传apk中...")
    String key = certBean.getBinary().getKey()
    String token = certBean.getBinary().getToken()
    String upload_url = certBean.getBinary().getUpload_url()

    String jsonApk = okHttpUtil.uploadApk(apkPath,key,token,appName,appVersion,appBuild,fileName,upload_url)
    println("上传apk文件返回结果:$jsonApk")

    // 上传icon
    println("上传Icon中...")
    String keyIcon = certBean.getIcon().getKey()
    String tokenIcon = certBean.getIcon().getToken()
    String upload_urlIcon = certBean.getIcon().getUpload_url()

    String jsonIcon = okHttpUtil.uploadIcon(apkIconPath,keyIcon,tokenIcon,upload_urlIcon)
    println("上传Icon返回结果:$jsonIcon")

    ApkInfo apkInfo = okHttpUtil.getApkUrl(appPackage,apiTokenFir)
    println("下载链接:${apkInfo.installUrl}")

    def (String content, String title, String webHook, boolean isAtAll,List<String> atMobiles) = getDingTalkParams()
    String dingTalkMsg = "点击跳转gilos下载链接(版本号:$appBuild 版本名称:$appVersion)"
    if (content.length() > 0){
    dingTalkMsg = "${dingTalkMsg},此次更新:$content"
    }

    /**
    * 发送钉钉消息
    */
    okHttpUtil.sendDingTalkLink(dingTalkMsg,title,apkInfo.installUrl,webHook)
    okHttpUtil.sendDingTalkMsg(content,webHook,isAtAll,atMobiles)
    }

    // 在assembleDebug执行后执行
    uploadFir.dependsOn project.tasks["assemble${variant.name.capitalize()}"]
    }
    }
    }

    // 获取钉钉消息配置相关的参数并返回
    private List getDingTalkParams() {
    String webHook = extension.getDingTalkExtension().getWebHook()
    String title = extension.getDingTalkExtension().getTitle()
    String content = extension.getDingTalkExtension().getContent()
    String isAtAll = extension.getDingTalkExtension().getIsAtAll()
    List<String> atMobiles = extension.getDingTalkExtension().getAtMobiles()
    [content, title, webHook, isAtAll, atMobiles]
    }

    // 获取相关gradle配置文件和fir配置相关的参数并返回
    private List getParams(Project project, variant) {
    String appName = extension.getFirExtension().getAppName()
    String appPackage = project.android.defaultConfig.applicationId
    String appVersion = project.android.defaultConfig.versionName
    String appBuild = project.android.defaultConfig.versionCode
    String apkPath = variant.outputs.first().outputFile
    String fileName = apkPath.substring(apkPath.lastIndexOf("\\") + 1, apkPath.length())
    String apkIconPath = project.android.applicationVariants.first().outputs.first().outputFile.parent.split("build")[0] + extension.getFirExtension().getIconPath()
    String apiTokenFir = extension.getFirExtension().getToken()
    // 获取上传凭证
    // println("appName:$appName")
    // println("appPackage:$appPackage")
    // println("appVersion:${appVersion}")
    // println("appBuild:${appBuild}")
    // println("apiTokenFir:${apiTokenFir}")
    // println("apkIconPath:${apkIconPath}")
    println("文件路径:$apkPath")
    println("文件名称:$fileName")
    [appPackage, apiTokenFir, apkPath, fileName, appName, appVersion, appBuild, apkIconPath]
    }
    }

    声明插件

    在lib的配置文件xxx.xxx.xxx.properties(xxx.xxx.xxx是插件定义类所在的包名)中声明插件
    1
    com.demo.plugin.PluginImpl

    获取所有打包任务下的插件执行命令

    variant.name.capitalize()这个参数是打包任务名称首字母大写,如不确定当前的打包任务名称,可以增加app的gradle代码,打印所有的打包任务名称来获取插件的执行命令,同步一下即可输出
    1
    2
    3
    project.android.applicationVariants.all { variant ->
    println("assemble${variant.name.capitalize()}Fir")
    }

    插件上传

    上传到maven仓库

    上传本地仓库实际上就是生成maven工程的本地文件夹再引用,方便调试
    增加插件lib的gradle文件代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    uploadArchives {
    repositories {
    mavenDeployer {
    repository(url: uri('../repo')) // 上传到本地仓库调试
    // 上传到远程仓库
    // repository(url: "xxx.xxx.xxx:xxxx/repo私有仓库maven地址") {
    // authentication(userName: "用户名", password: "密码")
    // }
    pom.groupId = 'com.demo.plugin'//插件lib包名
    pom.artifactId = 'firPlugin'
    pom.version = '0.4' //插件版本号
    }
    }
    }
    执行gradle uploadArchives命令上传

    上传到远程仓库(私有maven/公有maven)

使用插件

  • 修改根目录的gradle文件
    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
    // Top-level build file where you can add configuration options common to all sub-projects/modules.
    buildscript {
    ext.kotlin_version = "1.3.72"
    repositories {
    // maven { url './repo' } //本地Maven仓库地址
    // maven { url 'xxx.xxx.xxx:xxxx/repository/release'} // 私有仓库引用
    maven { url 'https://jitpack.io' } //Jitpack仓库引用

    google()
    jcenter()
    }
    dependencies {
    classpath "com.android.tools.build:gradle:4.0.0"
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    classpath 'com.github.jessieeeee:upload-apk-fir-plugin:0.7' //Jitpack插件引用
    // classpath 'com.demo.plugin:firPlugin:0.4' // 本地/私有仓库插件引用
    // NOTE: Do not place your application dependencies here; they belong
    // in the individual module build.gradle files
    }
    }

    allprojects {
    repositories {
    google()
    jcenter()
    }
    }

    task clean(type: Delete) {
    delete rootProject.buildDir
    }
  • 修改app的gradle文件,引入插件并进行相关配置
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    apply plugin: 'com.demo.plugin'
    uploadApk {
    fir {
    appName = "你的app名称"
    iconPath = "src/main/res/mipmap-xxxhdpi/ic_launcher.png"
    token = "fir平台的token"
    }
    dingTalk{
    webHook = "钉钉机器人的webhook"
    title = "Android:xxx打包完成"
    content = "带关键字的消息内容" //这个关键字跟自定义钉钉机器人的安全设置有关
    isAtAll = false // 是否at所有人
    atMobiles = ["手机号1","手机号2"] //at某些人
    }

    }
    最后贴上github完整项目地址:https://github.com/jessieeeee/upload-apk-fir-plugin,欢迎star,issue~


加密技术这块不仅涉及到很多JAVA基础,加密技术还涉及到很多Android底层知识,JAVA反射,JAVA IO,apk的启动流程,类的加载机制,dex文件的构造,APK打包的过程,而这些东西又正好是面试的常考点,是深入学习Android的必经之路,这次从原理入手,手写一个简单的加固框架,在这里做一个记录,如果有不对的地方欢迎指出和交流。


加固的主要目的是为了防止反编译,代码遭到阅读和窃取甚至重新打包上架的事情发生,那反编译的过程是什么呢?

  1. zip解压apk
  2. dex2jar把class.dex转成jar包
  3. jd-gui看class文件源码

加固的原理

所以加固的关键是对dex文件用加密算法进行加密,防止可执行部分的源码被阅读,此时就需要一个壳程序负责解密原dex文件,然后再合并原dex和壳dex重新签名打包成新的apk,运行时壳程序解密,获得原dex重新手动类加载

apk的打包流程

  1. APT工具处理资源文件(xml资源如布局、AndroidManifest),生成R.java
  2. AIDL工具处理AIDL文件,生成相应的Java文件
  3. Javac工具编译Java,生成Class文件
  4. DX工具将Class文件转换成DEX文件
  5. ApkBuilder工具将资源文件和DEX文件打包成APK
  6. KeyStore签名APK
  7. 正式版APK用ZipAlign工具对齐

实现加固

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
public static void main(String[] args) throws Exception {

byte[] mainDexData; // 存储源apk中的源dex文件
byte[] aarData; // 存储壳中的壳dex文件
byte[] mergeDex; // 存储壳dex 和源dex 的合并的新dex文件

// 删除source/apk/temp目录下所有文件
File tempFileApk = new File("source/apk/temp");
if (tempFileApk.exists()) {
File[]files = tempFileApk.listFiles();
for(File file: files){
if (file.isFile()) {
file.delete();
}
}
}
// 删除source/aar/temp目录下所有文件
File tempFileAar = new File("source/aar/temp");
if (tempFileAar.exists()) {
File[]files = tempFileAar.listFiles();
for(File file: files){
if (file.isFile()) {
file.delete();
}
}
}

//第一步 处理原始apk 加密dex
AES.init(AES.DEFAULT_PWD);
//待加固的apk
File apkFile = new File("source/apk/app-debug.apk");
//创建临时文件夹
File newApkFile = new File(apkFile.getParent() + File.separator + "temp");
if(!newApkFile.exists()) {
newApkFile.mkdirs();
}
//加密apk文件并写入到临时文件夹获取主dex
File mainDexFile = AES.encryptAPKFile(apkFile,newApkFile);
//临时文件夹存在,重命名dex文件
if (newApkFile.isDirectory()) {
File[] listFiles = newApkFile.listFiles();
for (File file : listFiles) {
if (file.isFile()) {
if (file.getName().endsWith(".dex")) {
String name = file.getName();
System.out.println("rename step1:"+name);
int cursor = name.indexOf(".dex");
String newName = file.getParent()+ File.separator + name.substring(0, cursor) + "_" + ".dex";
System.out.println("rename step2:"+newName);
file.renameTo(new File(newName));
}
}
}
}

// 第二步 处理aar 获得壳dex
File aarFile = new File("source/aar/mylibrary-debug.aar");
// jar包转dex文件
File aarDex = Dx.jar2Dex(aarFile);
//读取dex文件为byte数组
aarData = Utils.getBytes(aarDex);
// 创建一个classes.dex文件
File tempMainDex = new File(newApkFile.getPath() + File.separator + "classes.dex");
if (!tempMainDex.exists()) {
tempMainDex.createNewFile();
}
// 写入byte数组到classes.dex文件
FileOutputStream fos = new FileOutputStream(tempMainDex);
byte[] fbytes = Utils.getBytes(aarDex);
fos.write(fbytes);
fos.flush();
fos.close();


/**
* 第三步 打包签名
*/
// 创建未签名apk的文件夹
File unsignedApk = new File("result/apk-unsigned.apk");
unsignedApk.getParentFile().mkdirs();
// 合并壳dex和加密dex,压缩newApkFile中的文件为unsignedApk
Zip.zip(newApkFile, unsignedApk);
// 对unsignedApk文件签名输出签名后的文件apk-signed.apk
File signedApk = new File("result/apk-signed.apk");
Signature.signature(unsignedApk, signedApk);
}
}

Zip压缩工具类

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
public class Zip {

// 解压文件到目标文件夹
public static void unZip(File zip, File dir) {
try {
// 删除已存在的目标文件夹
dir.delete();
// 包装成压缩文件对象
ZipFile zipFile = new ZipFile(zip);
// 获取被压缩的所有文件
Enumeration<? extends ZipEntry> entries = zipFile.entries();
// 遍历被压缩的文件
while (entries.hasMoreElements()) {
ZipEntry zipEntry = entries.nextElement();
String name = zipEntry.getName();
// 如果是META-INF/CERT.RSA,META-INF/CERT.SF,META-INF/MANIFEST.MF文件就跳过
if (name.equals("META-INF/CERT.RSA") || name.equals("META-INF/CERT.SF") || name
.equals("META-INF/MANIFEST.MF")) {
continue;
}
// 如果当前压缩文件不是一个文件夹,就输出到目标文件夹
if (!zipEntry.isDirectory()) {
File file = new File(dir, name);
if (!file.getParentFile().exists()) file.getParentFile().mkdirs();
FileOutputStream fos = new FileOutputStream(file);
InputStream is = zipFile.getInputStream(zipEntry);
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
is.close();
fos.close();
}
}
zipFile.close();
} catch (Exception e) {
e.printStackTrace();
}
}

public static void zip(File dir, File zip) throws Exception {
// 删除已存在的压缩文件
zip.delete();
// 压缩文件并对输出文件做CRC32校验
CheckedOutputStream cos = new CheckedOutputStream(new FileOutputStream(
zip), new CRC32());
ZipOutputStream zos = new ZipOutputStream(cos);
compress(dir, zos, "");
zos.flush();
zos.close();
}

private static void compress(File srcFile, ZipOutputStream zos,
String basePath) throws Exception {
if (srcFile.isDirectory()) {
compressDir(srcFile, zos, basePath);
} else {
compressFile(srcFile, zos, basePath);
}
}

private static void compressDir(File dir, ZipOutputStream zos,
String basePath) throws Exception {
File[] files = dir.listFiles();
// 文件夹为空,构建空目录
if (files.length < 1) {
ZipEntry entry = new ZipEntry(basePath + dir.getName() + "/");
zos.putNextEntry(entry);
zos.closeEntry();
}
// 递归压缩
for (File file : files) {
compress(file, zos, basePath + dir.getName() + "/");
}
}

private static void compressFile(File file, ZipOutputStream zos, String dir)
throws Exception {
// 当前文件路径
String dirName = dir + file.getName();
// 文件新名称拼接
String[] dirNameNew = dirName.split("/");
StringBuffer buffer = new StringBuffer();

if (dirNameNew.length > 1) {
for (int i = 1; i < dirNameNew.length; i++) {
buffer.append("/");
buffer.append(dirNameNew[i]);
}
} else {
buffer.append("/");
}
// 创建压缩文件并写入数据
ZipEntry entry = new ZipEntry(buffer.toString().substring(1));
zos.putNextEntry(entry);
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(
file));
int count;
byte data[] = new byte[1024];
while ((count = bis.read(data, 0, 1024)) != -1) {
zos.write(data, 0, count);
}
bis.close();
zos.closeEntry();
}
}

AES对称加密工具类

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
public class AES {
// 默认密码
public static final String DEFAULT_PWD = "abcdefghijklmnop";
//
private static final String algorithmStr = "AES/ECB/PKCS5Padding";

private static Cipher encryptCipher;
private static Cipher decryptCipher;

public static void init(String password) {
try {
// 创建加密对象,ECB模式,PKCS5Padding填充方式
encryptCipher = Cipher.getInstance(algorithmStr);
// 创建解密对象
decryptCipher = Cipher.getInstance(algorithmStr);
// 获取密码字节数组
byte[] keyStr = password.getBytes();
// 生成加密密钥
SecretKeySpec key = new SecretKeySpec(keyStr, "AES");
// 初始化加密对象
encryptCipher.init(Cipher.ENCRYPT_MODE, key);
// 初始化解密对象
decryptCipher.init(Cipher.DECRYPT_MODE, key);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (NoSuchPaddingException e) {
e.printStackTrace();
} catch (InvalidKeyException e) {
e.printStackTrace();
}
}

/**
*
* @param srcAPKfile 源文件所在位置
* @param dstApkFile 目标文件
* @return 加密后的新dex 文件
* @throws Exception
*/
public static File encryptAPKFile(File srcAPKfile, File dstApkFile) throws Exception {
if (srcAPKfile == null) {
System.out.println("encryptAPKFile :srcAPKfile null");
return null;
}
// 解压源文件到目标文件夹
Zip.unZip(srcAPKfile, dstApkFile);
// 获得目标文件夹所有的dex
File[] dexFiles = dstApkFile.listFiles(new FilenameFilter() {
@Override
public boolean accept(File file, String s) {
return s.endsWith(".dex");
}
});

File mainDexFile = null;
byte[] mainDexData = null;
// 遍历所有的dex文件,找到并记录主dex文件并获得加密后的字节数组
for (File dexFile: dexFiles) {
// 获取dex的字节数组
byte[] buffer = Utils.getBytes(dexFile);
// 加密后的字节数组
byte[] encryptBytes = AES.encrypt(buffer);

if (dexFile.getName().endsWith("classes.dex")) {
mainDexData = encryptBytes;
mainDexFile = dexFile;
}
//用加密后的字节数组替换原来的数据
FileOutputStream fos = new FileOutputStream(dexFile);
fos.write(encryptBytes);
fos.flush();
fos.close();
}

// 返回主dex文件
return mainDexFile;
}

// 对字节数组加密返回
public static byte[] encrypt(byte[] content) {
try {
byte[] result = encryptCipher.doFinal(content);
return result;
} catch (IllegalBlockSizeException e) {
e.printStackTrace();
} catch (BadPaddingException e) {
e.printStackTrace();
}
return null;
}

// 字节数组解密返回
public static byte[] decrypt(byte[] content) {
try {
byte[] result = decryptCipher.doFinal(content);
return result;
} catch (IllegalBlockSizeException e) {
e.printStackTrace();
} catch (BadPaddingException e) {
e.printStackTrace();
}
return null;
}
}

dx转换工具类

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
public class Dx {

public static File jar2Dex(File aarFile) throws IOException, InterruptedException {
// 创建临时文件夹
File fakeDex = new File(aarFile.getParent() + File.separator + "temp");
// 解压aar到临时文件夹下
Zip.unZip(aarFile, fakeDex);
// 过滤找到classes.jar
File[] files = fakeDex.listFiles(new FilenameFilter() {
@Override
public boolean accept(File file, String s) {
return s.equals("classes.jar");
}
});
// aar文件不存在抛异常
if (files == null || files.length <= 0) {
throw new RuntimeException("the aar is invalidate");
}
// 将classes.jar转classes.dex
File classes_jar = files[0];
// 创建classes.dex文件
File aarDex = new File(classes_jar.getParentFile(), "classes.dex");

//使用android tools里面的dx.bat,调windows下的命令
Dx.dxCommand(aarDex, classes_jar);
return aarDex;
}

public static void dxCommand(File aarDex, File classes_jar) throws IOException, InterruptedException {
Runtime runtime = Runtime.getRuntime();
Process process = runtime.exec("cmd.exe /C dx --dex --output=" + aarDex.getAbsolutePath() + " " +
classes_jar.getAbsolutePath());

try {
process.waitFor();
} catch (InterruptedException e) {
e.printStackTrace();
throw e;
}
// 转换失败,输出错误到文件并抛异常
if (process.exitValue() != 0) {
InputStream inputStream = process.getErrorStream();
int len;
byte[] buffer = new byte[2048];
ByteArrayOutputStream bos = new ByteArrayOutputStream();
while((len=inputStream.read(buffer)) != -1){
bos.write(buffer,0,len);
}
System.out.println(new String(bos.toByteArray(),"GBK"));
throw new RuntimeException("dx run failed");
}
process.destroy();
}
}

签名工具类

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

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;

/**
* Created by LK on 2017/9/4.
*
*/
public class Signature {
public static void signature(File unsignedApk, File signedApk) throws InterruptedException, IOException {
// 执行windows下的签名命令
String cmd[] = {"cmd.exe", "/C ","jarsigner", "-sigalg", "MD5withRSA",
"-digestalg", "SHA1",
"-keystore", "C:/Users/allen/.android/debug.keystore",
"-storepass", "android",
"-keypass", "android",
"-signedjar", signedApk.getAbsolutePath(),
unsignedApk.getAbsolutePath(),
"androiddebugkey"};
Process process = Runtime.getRuntime().exec(cmd);
System.out.println("start sign");
try {
int waitResult = process.waitFor();
System.out.println("waitResult: " + waitResult);
} catch (InterruptedException e) {
e.printStackTrace();
throw e;
}
System.out.println("process.exitValue() " + process.exitValue() );
// 执行失败,输出错误到文件并抛异常
if (process.exitValue() != 0) {
InputStream inputStream = process.getErrorStream();
int len;
byte[] buffer = new byte[2048];
ByteArrayOutputStream bos = new ByteArrayOutputStream();
while((len=inputStream.read(buffer)) != -1){
bos.write(buffer,0,len);
}
System.out.println(new String(bos.toByteArray(),"GBK"));
throw new RuntimeException("sign run failed");
}
System.out.println("finish signed");
process.destroy();
}
}


随着JetPack的火热,google首推的Kotlin+LiveData+ViewModel+DataBinding的MVVM框架也越来越流行,在MVP的基础上,解决了P层带来的接口地狱和内存泄漏的问题,通信框架也从传统的handler,broadcast,interface到EventBus再到rxBus,最后到LiveDataBus,目前为止LiveDataBus确实是众多通信方案中最优的,在LiveData的加持下,拥有体积小,易封装,易维护且可感知生命周期防止内存泄漏的特点,接下来记录一下LiveDataBus的封装过程


通信框架对比

Handler 高耦合,不利于维护,内存泄漏

BroadCast 性能差,传输数据有限,打乱代码的执行逻辑

Interface 实现复杂,不利于维护

RxBus 基于RxJava,学习成本高且依赖大

EventBus 需解决混淆问题,无法感知生命周期,实现复杂

发布订阅模式和观察者模式区别

  • 观察者模式:观察者和被观察者相互知道对方的存在

  • 发布订阅模式:发布者和订阅者互相不知道对方的存在

什么是LiveData

数据持有类,持有数据并且这个数据可以被观察者监听,它是和LifeCycle绑定的,在生命周期内使用有效,减少内存泄漏和引用问题

LiveData的特点

  1. UI和数据保持一致:LiveData采用观察者模式,在数据变化时得到通知更新UI
  2. 避免内存泄漏:观察者被绑定到组件的生命周期上,组件销毁时,观察者会立刻清理数据
  3. 不会在Activity的stop状态下崩溃:当Activity处于后台,不会收到LiveData的延迟消息
  4. 解决屏幕旋转重启问题:能收到最新的数

LiveDataBus的封装

  1. 通过map维护一个消息事件和MutableLiveData的映射关系,MutableLiveData的类型默认为Object,接收任意类型,实现总线通信
  2. 将LiveDataBus封装为一个单例类
  3. 消息注册时,如果当前map中不存在,则先将消息和对应的MutableLiveData对象放入维护的map中,添加映射关系,返回当前map中缓存的MutableLiveData对象

粘性消息问题解决

具体现象:当前Activity给未启动的Activity发送一个消息,Activity在启动时能收到之前发送的消息

LiveData源码

LifecycleBoundObserver

1
2
3
4
5
6
7
8
9
@Override
public void onStateChanged(@NonNull LifecycleOwner source,
@NonNull Lifecycle.Event event) {
if (mOwner.getLifecycle().getCurrentState() == DESTROYED) {
removeObserver(mObserver);
return;
}
activeStateChanged(shouldBeActive());
}

LiveData的version初始化是-1,每次LiveData设置值都会version加1

1
2
3
4
5
6
7
8
private int mVersion = START_VERSION;
@MainThread
protected void setValue(T value) {
assertMainThread("setValue");
mVersion++;
mData = value;
dispatchingValue(null);
}

LifeCircleOwner的状态变化时,会调LiveData.ObserverWrapper的activeStateChanged方法,如果这个时候ObserverWrapper的状态是active,就会调用LiveData的dispatchingValue,继续跟踪considerNotify,如果ObserverWrapper的mLastVersion小于LiveData的mVersion,会调mObserver的onChanged方法。所以LiveDataBus注册一个新的订阅者就会收到消息,即使消息发生在订阅之前。

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
void activeStateChanged(boolean newActive) {
if (newActive == mActive) {
return;
}
// immediately set active state, so we'd never dispatch anything to inactive
// owner
mActive = newActive;
boolean wasInactive = LiveData.this.mActiveCount == 0;
LiveData.this.mActiveCount += mActive ? 1 : -1;
if (wasInactive && mActive) {
onActive();
}
if (LiveData.this.mActiveCount == 0 && !mActive) {
onInactive();
}
if (mActive) {
dispatchingValue(this);
}
}

void dispatchingValue(@Nullable ObserverWrapper initiator) {
if (mDispatchingValue) {
mDispatchInvalidated = true;
return;
}
mDispatchingValue = true;
do {
mDispatchInvalidated = false;
if (initiator != null) {
considerNotify(initiator);
initiator = null;
} else {
for (Iterator<Map.Entry<Observer<? super T>, ObserverWrapper>> iterator =
mObservers.iteratorWithAdditions(); iterator.hasNext(); ) {
considerNotify(iterator.next().getValue());
if (mDispatchInvalidated) {
break;
}
}
}
} while (mDispatchInvalidated);
mDispatchingValue = false;
}

private void considerNotify(ObserverWrapper observer) {
if (!observer.mActive) {
return;
}
// Check latest state b4 dispatch. Maybe it changed state but we didn't get the event yet.
//
// we still first check observer.active to keep it as the entrance for events. So even if
// the observer moved to an active state, if we've not received that event, we better not
// notify for a more predictable notification order.
if (!observer.shouldBeActive()) {
observer.activeStateChanged(false);
return;
}
if (observer.mLastVersion >= mVersion) {
return;
}
observer.mLastVersion = mVersion;
//noinspection unchecked
observer.mObserver.onChanged((T) mData);
}

Hook

在事件传递过程中拦截并监控事件的传输,修改事件传递流程
只要调用setValue版本号mVersion就会加1,此时版本号已经不一致导致onChange的调用,触发粘性事件,如果将mObservers.observer.mLastVersion修改为mVersion当前版本,就会在mObservers.observer.onChange调用前,也就是数据变化通知前return结束,这样就不调onChange方法
mObservers是Map对象,Map的item是键值对,observer是键值对的value,反射Map获取到Entry并获取到value也就是observer
继承MutableLiveData,重写observe方法,在注册监听时进行hook逻辑

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
public class LiveDataBus {
private final Map<String, BusMutableLiveData<Object>> bus;

private LiveDataBus() {
bus = new HashMap<>();
}

private static class SingletonHolder {
private static final LiveDataBus DEFAULT_BUS = new LiveDataBus();
}

public static LiveDataBus get() {
return SingletonHolder.DEFAULT_BUS;
}

public <T> MutableLiveData<T> with(String key, Class<T> type) {
if (!bus.containsKey(key)) {
bus.put(key, new BusMutableLiveData<>());
}
return (MutableLiveData<T>) bus.get(key);
}

public MutableLiveData<Object> with(String key) {
return with(key, Object.class);
}



private static class BusMutableLiveData<T> extends MutableLiveData<T> {

// 生命周期感知的注册监听处理,去除粘性事件
@Override
public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer) {
super.observe(owner, observer);
try {
hook(observer);
} catch (Exception e) {
e.printStackTrace();
}
}


// 去除粘性事件
private void hook(@NonNull Observer<T> observer) throws Exception {
//get wrapper's version
Class<LiveData> classLiveData = LiveData.class;
Field fieldObservers = classLiveData.getDeclaredField("mObservers");
fieldObservers.setAccessible(true);
Object objectObservers = fieldObservers.get(this);
Class<?> classObservers = objectObservers.getClass();
Method methodGet = classObservers.getDeclaredMethod("get", Object.class);
methodGet.setAccessible(true);
Object objectWrapperEntry = methodGet.invoke(objectObservers, observer);
Object objectWrapper = null;
if (objectWrapperEntry instanceof Map.Entry) {
objectWrapper = ((Map.Entry) objectWrapperEntry).getValue();
}
if (objectWrapper == null) {
throw new NullPointerException("Wrapper can not be bull!");
}
Class<?> classObserverWrapper = objectWrapper.getClass().getSuperclass();
Field fieldLastVersion = classObserverWrapper.getDeclaredField("mLastVersion");
fieldLastVersion.setAccessible(true);
//get livedata's version
Field fieldVersion = classLiveData.getDeclaredField("mVersion");
fieldVersion.setAccessible(true);
Object objectVersion = fieldVersion.get(this);
//set wrapper's version
fieldLastVersion.set(objectWrapper, objectVersion);
}
}
}

对非生命周期感知的observeForever方法,生成的wrapper不是LifecycleBoundObserver而是AlwaysActiveObserver,没有办法在observeForever调用完后再改AlwaysActiveObserver的version,因为注册监听时直接调了wrapper.activeStateChanged(true)而不是在LifeCircleOwner的状态变化时。

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
  @MainThread
public void observeForever(@NonNull Observer<? super T> observer) {
assertMainThread("observeForever");
AlwaysActiveObserver wrapper = new AlwaysActiveObserver(observer);
ObserverWrapper existing = mObservers.putIfAbsent(observer, wrapper);
if (existing != null && existing instanceof LiveData.LifecycleBoundObserver) {
throw new IllegalArgumentException("Cannot add the same observer"
+ " with different lifecycles");
}
if (existing != null) {
return;
}
wrapper.activeStateChanged(true);
}

private class AlwaysActiveObserver extends ObserverWrapper {

AlwaysActiveObserver(Observer<? super T> observer) {
super(observer);
}

@Override
boolean shouldBeActive() {
return true;
}
}

可以用ObserverWrapper,包装真正的回调传给observeForever,回调时检查调用栈,如果回调是observeForever方法,那么就不调真正的回调

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
// 包装类包裹真正的Observer,处理非生命周期感知的注册监听
private static class ObserverWrapper<T> implements Observer<T> {

private Observer<T> observer;

public ObserverWrapper(Observer<T> observer) {
this.observer = observer;
}

@Override
public void onChanged(@Nullable T t) {
if (observer != null) {
// 目标方法不调onChanged
if (isCallOnObserve()) {
return;
}
observer.onChanged(t);
}
}

private boolean isCallOnObserve() {
StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
if (stackTrace != null && stackTrace.length > 0) {
for (StackTraceElement element : stackTrace) {
// 如果当前是LiveData对象且为observeForever方法
if ("android.arch.lifecycle.LiveData".equals(element.getClassName()) &&
"observeForever".equals(element.getMethodName())) {
return true;
}
}
}
return false;
}
}

修改BusMutableLiveData增加对非生命周期感知的注册监听处理

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
  private static class BusMutableLiveData<T> extends MutableLiveData<T> {

private Map<Observer, Observer> observerMap = new HashMap<>();

// 生命周期感知的注册监听处理,去除粘性事件
@Override
public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer) {
super.observe(owner, observer);
try {
hook(observer);
} catch (Exception e) {
e.printStackTrace();
}
}

// 非生命周期感知的注册监听处理,去除粘性事件
@Override
public void observeForever(@NonNull Observer<T> observer) {
if (!observerMap.containsKey(observer)) {
observerMap.put(observer, new ObserverWrapper(observer));
}
super.observeForever(observerMap.get(observer));
}

// 非生命周期感知取消注册监听
@Override
public void removeObserver(@NonNull Observer<T> observer) {
Observer realObserver = null;
if (observerMap.containsKey(observer)) {
realObserver = observerMap.remove(observer);
} else {
realObserver = observer;
}
super.removeObserver(realObserver);
}

// 去除粘性事件
private void hook(@NonNull Observer<T> observer) throws Exception {
//get wrapper's version
Class<LiveData> classLiveData = LiveData.class;
Field fieldObservers = classLiveData.getDeclaredField("mObservers");
fieldObservers.setAccessible(true);
Object objectObservers = fieldObservers.get(this);
Class<?> classObservers = objectObservers.getClass();
Method methodGet = classObservers.getDeclaredMethod("get", Object.class);
methodGet.setAccessible(true);
Object objectWrapperEntry = methodGet.invoke(objectObservers, observer);
Object objectWrapper = null;
if (objectWrapperEntry instanceof Map.Entry) {
objectWrapper = ((Map.Entry) objectWrapperEntry).getValue();
}
if (objectWrapper == null) {
throw new NullPointerException("Wrapper can not be bull!");
}
Class<?> classObserverWrapper = objectWrapper.getClass().getSuperclass();
Field fieldLastVersion = classObserverWrapper.getDeclaredField("mLastVersion");
fieldLastVersion.setAccessible(true);
//get livedata's version
Field fieldVersion = classLiveData.getDeclaredField("mVersion");
fieldVersion.setAccessible(true);
Object objectVersion = fieldVersion.get(this);
//set wrapper's version
fieldLastVersion.set(objectWrapper, objectVersion);
}
}
}


注解和反射是Android开发的基础,也是项目框架搭建中用到的必不可少的技术,减少重复代码编写,提高开发效率,并且广泛用于知名的开源框架中,有利于我们阅读源码,同时提升自己的架构能力和封装基础库的能力。下面对注解和反射的学习做一个记录。


原注解

元注解是定义注解的注解

@Retention:该注解保留阶段,保留的时长, 源码(RetentionPolicy.SOURCE) < 字节码(RetentionPolicy.CLASS) < 运行时(RetentionPolicy.RUNTIME)

  • 源码级别的注解:应用于APT编译期处理注解生成JAVA代码,生成额外的辅助类,如Dagger2, ButterKnife, EventBus3
  • 字节码级别的注解:应用于字节码插桩,可用于埋点,如ASM,AspectJ
  • 运行时级别的注解:反射获取被注解标记的变量/方法/类的信息

@Target:该注解被使用的位置,字段枚举常量级(ElementType.FIELD),局部变量级(ElementType.LOCAL_VARIABLE),方法级(ElementType.METHOD),方法级(ElementType.PARAMETER),类级接口级(ElementType.TYPE),包级(ElementType.PACKAGE),构造方法(ElementType.CONSTRUCTOR),注解级(ElementType.ANNOTATION_TYPE)

注解+反射实现Intent参数传递

定义注解

用反射获取该变量的信息需保留到运行时阶段且注解应用于类的字段变量之上

1
2
3
4
5
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface AutoInject {
String key();
}

注入逻辑

  1. 获取到该Activity的class对象,获取到该Activity的Intent数据
  2. 没有任何传值时直接返回
  3. 如果有参数传递,获取到该Activity的所有字段变量
  4. 确定每个字段变量的传值key,遍历所有的字段变量,判断该字段变量是否被注解,如果被注解则获取到注解对象,判断注解上的参数传值是否为空,如果为空直接使用被注解的变量名称为key,不为空则使用注解上的参数传值为key
  5. 判断传递的参数中是否有该key的值,如果有获取传入的值,如果字段变量不为数组,这里传入的值为最终结果
  6. 获取被注解的变量类型,如果该变量是数组并且是序列化的类,强转对象数组,并复制一份新的对象数组为最终结果,修改Activity中该变量的访问权限,将结果赋值给该变量
    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
    public static void injectBundle(Activity activity){
    // 获取class对象
    Class<? extends Activity> cls = activity.getClass();
    Intent intent = activity.getIntent();
    Bundle bundle = intent.getExtras();
    // 如果没有传值返回
    if (bundle == null){
    return;
    }
    // 获取所有的变量
    Field[] fields = cls.getDeclaredFields();
    // 遍历activity的变量
    for(Field field: fields){
    // 判断是否被注解
    if (field.isAnnotationPresent(AutoInject.class)){
    // 获取到注解对象
    AutoInject autoInject = field.getAnnotation(AutoInject.class);
    // 判断注解传值是否为空,如果为空使用当前被注解的变量名称
    String key = TextUtils.isEmpty(autoInject.key()) ? field.getName() : autoInject.key();
    // 如果有该key的传值
    if (bundle.containsKey(key)){
    // 获取传入的值
    Object object = bundle.get(key);
    // 获取被注解的变量类型
    Class<?> componentType = field.getType().getComponentType();
    // 如果当前变量是数组并且是序列化的class
    if (field.getType().isArray() && Parcelable.class.isAssignableFrom(componentType)){
    // 强转对象数组
    Object[] objs = (Object[])object;
    // 复制到新的对象数组
    Object[] objects = Arrays.copyOf(objs, objs.length, (Class<? extends Object[]>) field.getType());
    object = objects;
    }
    // 修改该变量的访问权限
    field.setAccessible(true);
    try {
    // 设置当前activity该变量的值为传值对象
    field.set(activity,object);
    } catch (IllegalAccessException e) {
    e.printStackTrace();
    }
    }
    }
    }
    }

    使用

  • 第一个Activity传递参数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    //User是序列化对象
    User user1 = new User("小明",12);
    User user2 = new User("小王",13);
    User[] users = new User[2];
    users[0] = user1;
    users[1] = user2;
    ArrayList<User> userList = new ArrayList<User>();
    userList.add(user1);
    userList.add(user2);
    // 传对象
    intent.putExtra("test1",user1);
    // 传对象数组
    intent.putExtra("test2",users);
    // 传对象列表
    intent.putParcelableArrayListExtra("test3",userList);
  • 第二个Activity声明接收变量添加注解
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 接收对象
    @AutoInject(key = "test1")
    private User value1;
    // 接收对象数组
    @AutoInject(key = "test2")
    private User[] value4;
    // 接收对象列表
    @AutoInject(key = "test3")
    private ArrayList<User> value5;
    // Activity创建时调注入逻辑
    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    InjectUtils.injectBundle(this);
    }

注解+反射实现View.OnClick注入逻辑

定义注解

定义注解的注解

声明监听器类型,注入的方法

1
2
3
4
5
6
7
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface EventType {

Class listenerType();
String listenerSetter();
}

定义方法注解

用反射获取该变量的信息需保留到运行时阶段且注解应用于方法之上

普通点击监听的类型为View.OnClickListener.class,作用的方法为setOnClickListener

1
2
3
4
5
6
7
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@EventType(listenerType = View.OnClickListener.class, listenerSetter = "setOnClickListener")
public @interface OnClick {
int[] value();

}

长按监听的类型为View.OnLongClickListener.class,作用的方法为setOnLongClickListener

1
2
3
4
5
6
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@EventType(listenerType = View.OnLongClickListener.class, listenerSetter = "setOnLongClickListener")
public @interface OnLongClick {
int[] value();
}

注入逻辑

  1. 获取到该Activity的class对象,获取到当前Activity的所有方法
  2. 遍历所有方法,获取到方法的所有注解
  3. 遍历所有注解,获取到当前注解类型
  4. 如果是EventType目标注解,获取到注解对象,获取到注解上定义的传值,监听的Class类型,注解作用的方法
  5. 获取到方法上注解传入的id
  6. 修改方法的访问权限
  7. 利用Java的代理器生成代理对象,动态代理OnClickListener/OnLongClickListener接口
  8. 自定义InvocationHandler添加在对应的点击事件上注入的逻辑
  9. 获取到View对象,在对应的点击方法上注入代理对象
    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
    public class InjectClick {
    public static void injectEvent(Activity activity){
    // 获取到当前的activity的class对象
    Class<? extends Activity> activityClass = activity.getClass();
    // 获取到当前activity的所有方法
    Method[] methods = activityClass.getDeclaredMethods();
    // 遍历所有方法
    for (Method method: methods){
    // 获取到方法的所有注解
    Annotation[] annotations = method.getAnnotations();
    // 遍历所有注解
    for (Annotation annotation: annotations){
    // 获取到注解的类型
    Class<? extends Annotation> annotationType = annotation.annotationType();
    // 如果是EventType的注解
    if (annotationType.isAnnotationPresent(EventType.class)){
    // 获取到注解对象
    EventType eventType = annotationType.getAnnotation(EventType.class);
    // 获取到注解上定义的传值
    Class listenerType = eventType.listenerType();
    String listenerSetter = eventType.listenerSetter();
    try{
    // 获取到注解传入的id值
    Method valueMethod = annotationType.getDeclaredMethod("value");
    int[] viewIds = (int[]) valueMethod.invoke(annotation);
    method.setAccessible(true);
    ListenerInvocationHandler<Activity> handler = new ListenerInvocationHandler(activity, method);
    // OnClickListener/OnLongClickListener的代理对象
    Object listenerProxy = Proxy.newProxyInstance(listenerType.getClassLoader(),
    new Class[]{listenerType}, handler);

    // 遍历传入的id
    for (int viewId : viewIds) {
    // 获得view
    View view = activity.findViewById(viewId);
    // 获得OnClickListener/OnLongClickListener的setOnClickLisnter/setOnLongClickLisnter方法
    Method setter = view.getClass().getMethod(listenerSetter, listenerType);
    // 在View的点击方法上注入代理对象
    setter.invoke(view, listenerProxy);
    }
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    }
    }
    }


    /**
    * 兼容自定义view注入,所以是泛型: T = Activity/View
    *
    * @param <T>
    */
    static class ListenerInvocationHandler<T> implements InvocationHandler {

    private Method method;
    private T target;

    public ListenerInvocationHandler(T target, Method method) {
    this.target = target;
    this.method = method;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Log.v("injectClick","注入点击事件逻辑");
    return this.method.invoke(target, args);
    }
    }

使用

声明对应的点击回调,并添加注解,传入被注入点击事件View的Id

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
@OnClick({R.id.text, R.id.button})
public void click(View view) {
switch (view.getId()) {
case R.id.text:
Log.i("click", "click: 按钮1");
break;
case R.id.button:
Log.i("click", "click: 按钮2");
break;
}
}

@OnLongClick({R.id.text, R.id.button})
public boolean longClick(View view) {
switch (view.getId()) {
case R.id.text:
Log.i("click", "longClick: 按钮1");
break;
case R.id.button:
Log.i("click", "longClick: 按钮2");
break;
}
return false;
}

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
InjectClick.injectEvent(this);
}


又是很久没有写博客了,疫情结束后经济萧条,很多年轻人受疫情的影响而被迫离职至今未找到工作。今年受疫情影响,招聘需求明显萎缩,离职人员近几个月大幅增加,这应该是最难找工作的一年,相信不管是在职或离职的小伙伴应该都不太轻松。有工作经验的人尚且如此,今年毕业的应届生求职可能会更加艰难。生活不易,但是我们仍要以积极的心态去应对,相信终有一天这一切都会过去,生活又美好如初。受疫情影响今年我也经历了找工作这段痛苦的日子,受老天眷顾找到了一份新的工作,但仍然感觉自己的技术实力还需提高,作为程序员扎实的基础是核心竞争力中不可缺少的一部分,在扎实的基础下进一步扩展深度,学习更多的计算机底层原理,同时扩展广度,学习当下的新技术,只有保证自己的核心竞争力,才能在任何时候面对危机和考验从容应对。代理模式是设计模式中的常考点且很多开源框架都用到了这个模式,有必要学习并加深理解,在此做一个学习记录。


静态代理

外地拼搏的年轻人总是要面对租房的问题,这里以租房为例,理解静态代理模式。首先我们需要一个租赁接口,这个接口中只有一个方法就是租房

1
2
3
public interface Rent {
void rentHouse();
}

有一个年轻人叫小明,在外地拼搏的他需要租房,需要继承租赁这个接口并实现租房的方法

1
2
3
4
5
6
public class XiaoMing implements Rent{
@Override
public void rentHouse() {
Log.v("proxy","小明需要租房");
}
}

有一个中介机构链家可以帮你租房,你将租房需求告诉他们,当你满意成功租房后需要支付给他们中介费

1
2
3
4
5
6
7
8
9
10
11
12
13
public class LianJia implements Rent{

private Rent rent;
public LianJia(Rent rent){
this.rent = rent;
}
@Override
public void rentHouse() {
Log.v("proxy","链家获取你的租房需求");
rent.rentHouse();
Log.v("proxy","链家帮你租房");
}
}

接下来让链家帮小明租房

1
2
3
4
5
6
// 声明一个有租房需求的人小明
Rent xiaoMing = new XiaoMing();
// 声明中介机构链家,接受小明的租房需求
LianJia lianJia = new LianJia(xiaoMing);
// 链家帮小明租房
lianJia.rentHouse();

动态代理

Java中有个代理器可以实现接口对象的代理并生成对应的代理对象,我们利用Proxy.newProxyInstance生成实现了Rent接口的小明并生成代理对象,invoke方法中第一个参数是代理对象,第二个参数是被代理的方法,第三个参数是当前方法传入的参数值

1
2
3
4
5
6
Object o = Proxy.newProxyInstance(SplashActivity.class.getClassLoader(), new Class[]{Rent.class}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
return method.invoke(xiaoMing,args);
}
});

将代理对象转化为Rent接口的实例并调用租房的方法

1
2
3
// 代理生成器的代理对象
Rent xiaoMingProxy = (Rent) o;
xiaoMingProxy.rentHouse();

手写Retrofit框架

Retrofit的核心就是通过动态代理,将注解参数拼接成一个完整的http请求再给网络请求框架去处理

自定义注解

Field

1
2
3
4
5
@Target(ElementType.PARAMETER) // 表单提交,作用在POST请求的参数上
@Retention(RetentionPolicy.RUNTIME) // 运行期保留
public @interface Field {
String value();
}

GET

1
2
3
4
5
@Target(ElementType.METHOD) // 声明GET请求,作用在方法上
@Retention(RetentionPolicy.RUNTIME) // 运行期保留
public @interface GET {
String value();
}

POST

1
2
3
4
5
@Target(ElementType.METHOD) // 声明POST请求,作用在方法上
@Retention(RetentionPolicy.RUNTIME) // 运行期保留
public @interface POST {
String value();
}

Query

1
2
3
4
5
@Target(ElementType.PARAMETER) // url上拼接,作用在请求的参数上
@Retention(RetentionPolicy.RUNTIME) // 运行期保留
public @interface Query {
String value();
}

实现Retrofit

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
public class MyRetrofit {
final Map<Method, ServiceMethod> serviceMethodMap = new ConcurrentHashMap<>(); // 缓存调用方法到方法参数解析服务的映射
final Call.Factory callFactory;// 网络请求框架
final HttpUrl baseUrl;// 请求服务器url地址

public MyRetrofit(Call.Factory callFactory, HttpUrl baseUrl) {
this.callFactory = callFactory;
this.baseUrl = baseUrl;
}

// 返回请求接口的代理对象
public <T> T create(final Class<T> service) {
return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class[]{service},
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//解析方法所有注解信息
ServiceMethod serviceMethod = loadServiceMethod(method);
//传入参数的值并返回拼接好的请求
return serviceMethod.invoke(args);
}
});
}

// 双琐式创建实例
private ServiceMethod loadServiceMethod(Method method){
ServiceMethod serviceMethod = serviceMethodMap.get(method);
if (serviceMethod == null){
synchronized (serviceMethodMap){
// 直接取出对应的方法参数解析服务
serviceMethod = serviceMethodMap.get(method);
// 如果没有缓存就初始化调用,再放入缓存
if (serviceMethod == null){
serviceMethod = new ServiceMethod.Builder(this,method).build();
serviceMethodMap.put(method,serviceMethod);
}
}
}
return serviceMethod;
}

// 接收外部传入的参数并构建实例
public static final class Builder{
private HttpUrl baseUrl;
private Call.Factory callFactory;
public Builder callFactory(Call.Factory callFactory){
this.callFactory = callFactory;
return this;
}

public Builder baseUrl(String url){
this.baseUrl = HttpUrl.parse(url);
return this;
}

public MyRetrofit build(){
if (baseUrl == null){
throw new IllegalStateException("base url required");
}
Call.Factory callFactory = this.callFactory;
if(callFactory == null){
callFactory = new OkHttpClient();
}
return new MyRetrofit(callFactory, baseUrl);
}
}
}

实现方法参数解析服务

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
public class ServiceMethod {
private final Call.Factory callFactory;// 网络请求框架
private final String url; // 当前的请求路径
private final boolean hasBody; // 是否有请求体
private final ParameterHandler[] parameterHandlers;
private FormBody.Builder formBuild; // 请求体表单
HttpUrl baseUrl;// 请求服务器url
String httpMethod; // 请求方式post/get
HttpUrl.Builder urlBuilder;

public ServiceMethod(Builder builder) {
baseUrl = builder.retrofit.baseUrl;
callFactory = builder.retrofit.callFactory;

httpMethod = builder.httpMethod;
url = builder.url;
hasBody = builder.hasBody;
parameterHandlers = builder.parameterHandlers;

//如果有请求体,创建okhttp请求体对象
if (hasBody) {
formBuild = new FormBody.Builder();
}
}

// 处理传参
public Object invoke(Object[] args) {
// 处理请求的地址与参数
for (int i = 0; i < parameterHandlers.length; i++) {
ParameterHandler handlers = parameterHandlers[i];
//handler内本来就记录了key,现在给到对应的value
handlers.apply(this, args[i].toString());
}

//获取最终请求地址
HttpUrl httpUrl;
if (urlBuilder == null) {
urlBuilder = baseUrl.newBuilder(url);
}
httpUrl = urlBuilder.build();

//请求体
FormBody formBody = null;
if (formBuild != null) {
formBody = formBuild.build();
}

// 最后拼接成功的请求
Request request = new Request.Builder().url(httpUrl).method(httpMethod, formBody).build();
return callFactory.newCall(request);
}

// get请求, 按http的方式处理参数
public void addQueryParameter(String key, String value) {
if (urlBuilder == null) {
urlBuilder = baseUrl.newBuilder(url);
}
urlBuilder.addQueryParameter(key, value);
}

//Post请求, 按http的方式处理参数
public void addFiledParameter(String key, String value) {
formBuild.add(key, value);
}

public static class Builder{
private final MyRetrofit retrofit;
private final Annotation[] methodAnnotations;
private final Annotation[][] parameterAnnotations;
ParameterHandler[] parameterHandlers;
private String httpMethod;
private String url;
private boolean hasBody;

public Builder(MyRetrofit retrofit, Method method) {
this.retrofit = retrofit;
//获取方法的所有注解
methodAnnotations = method.getAnnotations();
//获取方法参数的所有注解
parameterAnnotations = method.getParameterAnnotations();
}

public ServiceMethod build() {

//处理POST与GET
for (Annotation methodAnnotation : methodAnnotations) {
if (methodAnnotation instanceof POST) {
//记录请求方式
this.httpMethod = "POST";
//记录请求url的path
this.url = ((POST) methodAnnotation).value();
// 是否有请求体
this.hasBody = true;
} else if (methodAnnotation instanceof GET) {
this.httpMethod = "GET";
this.url = ((GET) methodAnnotation).value();
this.hasBody = false;
}
}

// 处理方法参数的注解
int length = parameterAnnotations.length;
// 创建请求参数映射数组
parameterHandlers = new ParameterHandler[length];
for (int i = 0; i < length; i++) {
// 一个参数的所有注解
Annotation[] annotations = parameterAnnotations[i];
// 处理每一个注解
for (Annotation annotation : annotations) {
// 如果是Field注解
if (annotation instanceof Field) {
//得到注解上的value也就是请求参数的key
String value = ((Field) annotation).value();
// 传入参数的key
parameterHandlers[i] = new ParameterHandler.FieldParameterHandler(value);
}
// 如果是Query注解
else if (annotation instanceof Query) {
String value = ((Query) annotation).value();
parameterHandlers[i] = new ParameterHandler.QueryParameterHandler(value);

}
}
}

return new ServiceMethod(this);
}
}

}
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
public abstract class ParameterHandler {
abstract void apply(ServiceMethod serviceMethod, String value);
static class QueryParameterHandler extends ParameterHandler{
String key;
public QueryParameterHandler(String key){
this.key = key;
}

@Override
void apply(ServiceMethod serviceMethod, String value) {
serviceMethod.addQueryParameter(key,value);
}
}

static class FieldParameterHandler extends ParameterHandler{
String key;
public FieldParameterHandler(String key) {
this.key = key;
}

@Override
void apply(ServiceMethod serviceMethod, String value) {
serviceMethod.addFiledParameter(key,value);
}
}
}

定义网络请求Api

1
2
3
4
5
6
7
public interface WeatherApi {
@POST("/v3/weather/weatherInfo")
Call postWeather(@Field("city") String city, @Field("key") String key);

@GET("/v3/weather/weatherInfo")
Call getWeather(@Query("city") String city, @Query("key") String key);
}

自定义Retrofit发起请求

初始化Retrofit

1
2
MyRetrofit myRetrofit = new MyRetrofit.Builder().baseUrl("https://restapi.amap.com").build();
weatherApi = myRetrofit.create(WeatherApi.class);

发起post请求

1
2
3
4
5
6
7
8
9
10
11
12
13
okhttp3.Call getCall = weatherApi.getWeather("110101", "ae6c53e2186f33bbf240a12d80672d1b");
getCall.enqueue(new okhttp3.Callback() {
@Override
public void onFailure(okhttp3.Call call, IOException e) {

}

@Override
public void onResponse(okhttp3.Call call, okhttp3.Response response) throws IOException {
Log.i("onResponse", "onResponse enjoy get: " + response.body().string());
response.close();
}
});

发起get请求

1
2
3
4
5
6
7
8
9
10
11
12
13
okhttp3.Call postCall = weatherApi.postWeather("110101", "ae6c53e2186f33bbf240a12d80672d1b");
postCall.enqueue(new okhttp3.Callback() {
@Override
public void onFailure(okhttp3.Call call, IOException e) {

}

@Override
public void onResponse(okhttp3.Call call, okhttp3.Response response) throws IOException {
Log.i("onResponse", "onResponse enjoy post: " + response.body().string());
response.close();
}
});


又是很久没有写博客了,最近由于武汉疫情,这个春节从初一起就全程宅家,春节假期一延再延,本该上班的我们依然没有正常返工,街上仍然是没有几个人,快递延迟,很多人仍然是宅在家里远程上班。停工不停学,之前的漫画项目主要使用了网易漫画和腾讯漫画爬取的数据,而网易漫画在不久前被bilibili收购了,现在正式改为bilibili漫画,所以之前的爬虫逻辑和接口失效了,正好趁着这个时间把之前的服务端数据爬取接口改一下,这里做一个简单的记录。


爬取漫画列表和漫画详情都没什么问题,跟之前的思路一样,改一下对应的标签重新绑定目标数据,但是在爬去漫画内容的时候,发现漫画图片的链接已经不在html的标签中了,而是直接获取到服务端返回的图片地址后用canvas绘制出来的。如下图所示:
截图

所以只要我们能获取到该页面的网络请求结果,我们就能过滤出图片地址,也就不用去标签中获取目标数据了,接下来我发现在chrome浏览器中元素审查界面的网络拦截器中可以找到漫画内容的图片链接,如下图所示:
截图

所以只要我们目前使用的爬虫框架puppeteer能够拦截到网络请求的结果就可以解决标签中无法爬取到图片地址的问题了。我查了一下puppeteer的官方文档,发现了这些api

开启拦截

page.setRequestInterception(true)

监听服务端返回
page.on('response')

另外还可以监听当前页面的请求

page.on('request')

返回一个自定义的响应

req.respond()

根据当前的场景,我们需要获取服务器返回的数据,并过滤其中的漫画图片地址

https://manga.hdslb.com/bfs/manga/a39f3fd06e540fe14b7e591ced413f372bd9f85f.jpg@660w.jpg?token=3a96fd02961137c00a76145fb381d544&ts=5e3a7d62

https://manga.hdslb.com/bfs/manga/4cd38fde6581e146c249373c9ed120b75047004a.jpg@660w.jpg?token=2e740fd7ccfefec3f1f5d0d27e925e33&ts=5e3a7d62

https://manga.hdslb.com/bfs/manga/18a6e2e4739e7e3eb9888e7220b398fb2d0def9d.jpg@660w.jpg?token=0e8b573c3c40fa3c5554d2df6ec8b2cb&ts=5e3a7d62

https://manga.hdslb.com/bfs/manga/e6cbe3162d3c4b6e1175557a90d4a0e54562032f.jpg@660w.jpg?token=0d1a55fe1d27d3527a9340034cd5a35f&ts=5e3a7d62

https://manga.hdslb.com/bfs/manga/0c649ad9107997801dd4e45179323381b16dc50a.jpg@660w.jpg?token=fbdf6eb7df231dbe345f4411a32d56c6&ts=5e3a7d62

以上的链接地址特征

  1. https://manga.hdslb.com/bfs/manga/开头
  2. 尾部都跟有token和ts的参数,?token=&ts=
  3. @660w.jpg看起来是传入了请求图片的宽度和图片格式
  4. 抛开@后面的尾部参数,请求的图片格式和@后面的尾部参数传入的格式一致

根据特征,抓取漫画图片代码如下:

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
const puppeteer = require('puppeteer')
const browser = await puppeteer.launch({
headless: false,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
})
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('response',
function (response){
let url = response.url().toString()
let tokenStart = url.indexOf('?token=')
let tsStart = url.indexOf('&ts=')
// 捕获目标url
if (url.indexOf('manga.hdslb.com/bfs/manga/') !== -1
&& tokenStart !== -1
&& tsStart !== -1){
// 尾部参数请求的格式
let imgParamStart = url.indexOf('@')
if (imgParamStart !== -1){
// 图片本身的格式
let suffix = url.substring(imgParamStart - 3, imgParamStart)
// 请求图片的参数
let imgParam = url.substring(imgParamStart, tokenStart)
let imgWidthEnd = imgParam.indexOf('w')
// 请求图片的宽度
let imgWidth = imgParam.substring(1, imgWidthEnd)
let imgFormatStart = imgParam.indexOf('.')
// 请求图片的格式
let imgFormat = imgParam.substring(imgFormatStart + 1,tokenStart)
// 过滤图片信息
if (suffix === imgFormat){
console.log(response.url())
let data = response.url().toString();
let imgHeight = 1320
resolve({data, imgWidth, imgHeight})
}
}
}
}
)
// 跳转到目标网站
await page.goto(url)


又是很长一段时间没写博客了,最近工作繁忙,加班多,也是无奈啊~但是好习惯还是应该坚持下去的,平时工作钉钉作为主要的沟通工具,发现它除了是个聊天软件以外,还有一个好玩的东西-钉钉机器人,做些自动化推送提醒还是不错的,之前在telegram看到过类似的东西,它有一套专属api,可发送一些自定义的消息,实现一些自动化功能,目前推送提醒的解决方案一般是app推送,短信,企业微信,邮件,为了推送提醒单独开发一个app成本太高,短信现在几乎都是收费的,企业微信注册麻烦,而邮件一般都不会及时去看的。钉钉机器人创建成本低,又是主力聊天工具之一,对于个人或群组推送还是很实用的,随便拉两个人创建一个群就可以添加机器人了,如果只是做个人提醒的话,创建好后可以把这两个人T掉。唯一的限制是1秒最多发送20条~由于个人用iphone手机,本土化做得不好,节假日后补班那几天经常因为忘记定闹钟而睡过头,又不想下载第三方app,准备用家里有个树莓派做个人小型服务器,每天晚上定时跑一个python脚本,提醒我明天是否上班,如果上班提醒我设好闹钟,并请求天气预报,如果明天上班且下雨,提醒我闹钟提前半个小时,下雨早点出门不堵啊。


创建钉钉机器人

只要是个群组即可创建钉钉机器人,先拉两个人组成群组,在群组菜单中选择群组助手,添加机器人选择自定义机器人,创建的时候填写机器人名字,这里需要复制webhook的url链接,安全设置中可勾选自定义关键字,签名,ip地址,这里为了简单选择自定义关键字,设置为“提醒”,只要发送内容中带了关键字“提醒”即可。如果选择签名的话需要参考官方的签名算法,签名需添加到webhook的url上,如果选择ip地址的话,只有该ip地址的服务器才可以调用api发送消息。

钉钉机器人发送消息

发起post请求,参数为json格式,就是机器人发送的内容,请求的地址就是创建机器人的webhook,如果安全设置选择了签名的话要带上签名参数。

1
2
3
4
5
6
7
8
import requests
import json
def messageRobot(msg):
url = '你的webhook'
headers = {
'Content-Type': 'application/json'
}
requests.post(url, data=json.dumps(msg), headers=headers)

获取日期信息

找了一个免费api,http://timor.tech/api/holiday/info/{yyyy-MM-dd},{yyyy-MM-dd}为要查询的日期,请求结果如下:
如果该接口请求失败,code = 1,也需要给自己发送接口请求失败的消息,及时处理
type = 0, 明天是正常的工作日
request url:http://timor.tech/api/holiday/info/2019-10-11

1
2
3
4
5
6
7
8
9
{
"code": 0,
"type": {
"type": 0,
"name": "周五",
"week": 5
},
"holiday": null
}

type = 1, 明天是正常的双休日
request url: http://timor.tech/api/holiday/info/2019-10-13

1
2
3
4
5
6
7
8
9
{
"code": 0,
"type": {
"type": 1,
"name": "周日",
"week": 7
},
"holiday": null
}

type = 2, 明天是法定节假日
request url:http://timor.tech/api/holiday/info/2019-10-1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"code": 0,
"type": {
"type": 2,
"name": "国庆节",
"week": 2
},
"holiday": {
"holiday": true,
"name": "国庆节",
"wage": 3,
"date": "2019-10-01"
}
}

type = 3, 明天是补班的特殊日子
request url:http://timor.tech/api/holiday/info/2019-10-12

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"code": 0,
"type": {
"type": 3,
"name": "国庆节后调休",
"week": 6
},
"holiday": {
"holiday": false,
"name": "国庆节后调休",
"after": true,
"wage": 1,
"target": "国庆节",
"date": "2019-10-12"
}
}

获取天气预报

也是找了一个免费Api,http://t.weather.sojson.com/api/weather/city/{citycode},参数是城市编码,以成都为例,请求结果如下:

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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
{
"message": "success感谢又拍云(upyun.com)提供CDN赞助",
"status": 200,
"date": "20200307",
"time": "2020-03-07 22:52:50",
"cityInfo": {
"city": "成都市",
"citykey": "101270101",
"parent": "四川",
"updateTime": "22:30"
},
"data": {
"shidu": "63%",
"pm25": 52.0,
"pm10": 79.0,
"quality": "良",
"wendu": "14",
"ganmao": "极少数敏感人群应减少户外活动",
"forecast": Array[15][
{
"date": "07",
"high": "高温 19℃",
"low": "低温 12℃",
"ymd": "2020-03-07",
"week": "星期六",
"sunrise": "07:25",
"sunset": "19:06",
"aqi": 94,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "08",
"high": "高温 18℃",
"low": "低温 11℃",
"ymd": "2020-03-08",
"week": "星期日",
"sunrise": "07:24",
"sunset": "19:07",
"aqi": 57,
"fx": "无持续风向",
"fl": "<3级",
"type": "阴",
"notice": "不要被阴云遮挡住好心情"
},
{
"date": "09",
"high": "高温 17℃",
"low": "低温 8℃",
"ymd": "2020-03-09",
"week": "星期一",
"sunrise": "07:22",
"sunset": "19:08",
"aqi": 52,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "10",
"high": "高温 18℃",
"low": "低温 8℃",
"ymd": "2020-03-10",
"week": "星期二",
"sunrise": "07:21",
"sunset": "19:08",
"aqi": 59,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "11",
"high": "高温 15℃",
"low": "低温 9℃",
"ymd": "2020-03-11",
"week": "星期三",
"sunrise": "07:20",
"sunset": "19:09",
"aqi": 55,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "12",
"high": "高温 17℃",
"low": "低温 11℃",
"ymd": "2020-03-12",
"week": "星期四",
"sunrise": "07:19",
"sunset": "19:10",
"aqi": 62,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "13",
"high": "高温 15℃",
"low": "低温 11℃",
"ymd": "2020-03-13",
"week": "星期五",
"sunrise": "07:18",
"sunset": "19:10",
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "14",
"high": "高温 20℃",
"low": "低温 12℃",
"ymd": "2020-03-14",
"week": "星期六",
"sunrise": "07:16",
"sunset": "19:11",
"fx": "东北风",
"fl": "<3级",
"type": "阴",
"notice": "不要被阴云遮挡住好心情"
},
{
"date": "15",
"high": "高温 15℃",
"low": "低温 11℃",
"ymd": "2020-03-15",
"week": "星期日",
"sunrise": "07:15",
"sunset": "19:12",
"fx": "东北风",
"fl": "<3级",
"type": "阴",
"notice": "不要被阴云遮挡住好心情"
},
{
"date": "16",
"high": "高温 14℃",
"low": "低温 9℃",
"ymd": "2020-03-16",
"week": "星期一",
"sunrise": "07:14",
"sunset": "19:12",
"fx": "东北风",
"fl": "<3级",
"type": "阴",
"notice": "不要被阴云遮挡住好心情"
},
{
"date": "17",
"high": "高温 16℃",
"low": "低温 10℃",
"ymd": "2020-03-17",
"week": "星期二",
"sunrise": "07:13",
"sunset": "19:13",
"fx": "南风",
"fl": "<3级",
"type": "小雨",
"notice": "雨虽小,注意保暖别感冒"
},
{
"date": "18",
"high": "高温 20℃",
"low": "低温 9℃",
"ymd": "2020-03-18",
"week": "星期三",
"sunrise": "07:12",
"sunset": "19:14",
"fx": "南风",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "19",
"high": "高温 22℃",
"low": "低温 11℃",
"ymd": "2020-03-19",
"week": "星期四",
"sunrise": "07:10",
"sunset": "19:14",
"fx": "东南风",
"fl": "<3级",
"type": "阴",
"notice": "不要被阴云遮挡住好心情"
},
{
"date": "20",
"high": "高温 23℃",
"low": "低温 12℃",
"ymd": "2020-03-20",
"week": "星期五",
"sunrise": "07:09",
"sunset": "19:15",
"fx": "东北风",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "21",
"high": "高温 22℃",
"low": "低温 13℃",
"ymd": "2020-03-21",
"week": "星期六",
"sunrise": "07:08",
"sunset": "19:16",
"fx": "西北风",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
}
],
"yesterday": {
"date": "06",
"high": "高温 16℃",
"low": "低温 10℃",
"ymd": "2020-03-06",
"week": "星期五",
"sunrise": "07:26",
"sunset": "19:06",
"aqi": 79,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
}
}
}

如果请求成功status=200,data的forecast字段是包括今天以及未来14天的天气情况,所以这个字段下的第二个元素就是明天的天气预报,判断该元素下的type字段是否包含“雨”,并返回调用结果,如果接口请求失败也给自己发送一条消息,及时处理

python3实现

每天定时跑脚本,给自己发钉钉消息,并结合明天的天气预报,如果要下雨给自己发送要早起的提示

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
import json
import requests
import datetime
import time
def getWeather():
url = "http://t.weather.sojson.com/api/weather/city/{citycode}"
response = requests.get(url)
logWithTime("getWeather ---> " + response.text)
json_data = json.loads(response.text)
status = json_data.get('status')
if status == 200:
# 明天的天气信息
tomorrow = json_data.get('data').get('forecast')[1]

# 明天的天气
tomorrow_weather = str(tomorrow.get("type"))
if "雨" in tomorrow_weather:
return 1
else:
return 0
else:
return -1

def getDateInfo():
#获得今天的日期
today = datetime.date.today()
#获得明天的日期
tomorrow = today + datetime.timedelta(days=1)
url = "http://timor.tech/api/holiday/info/" + str(tomorrow)
response = requests.get(url)
logWithTime("getDateInfo ---> " + response.text)
json_data = json.loads(response.text)
dayType = json_data.get('type').get('type')
code = json_data.get('code')
holiday = json_data.get('holiday')

if code == 1:
logWithTime("getDateInfoError ---> ")
requestError(True)
else:
if(dayType == 0): #正常上班
messageWorkNormal(getWeather())
elif(dayType == 1 or dayType == 2): #普通周末和节假日
messageHoliday()
elif(dayType == 3): # 补假日
messageWorkAbnormal(today, str(holiday.get('name')),getWeather())



# 休息日
def messageHoliday():
logWithTime("messageHoliday ---> ")
messageRobot({
"msgtype": "text",
"text": {
"content": "【闹钟提醒】明天休息日,不上班哦~"
}
})

# 正常上班
def messageWorkNormal(rain):
if rain == -1:
logWithTime("getWeatherError ---> ")
requestError(False)
else:
str = ""
if rain == 1:
logWithTime("messageWorkNormal rain ---> ")
str = "可能下雨,需提前出门,"
logWithTime("messageWorkNormal ---> ")
messageRobot({
"msgtype": "text",
"text": {
"content": "【闹钟提醒】明天要上班,"+ str +"注意添加闹钟!!!"
}
})


# 接口请求出错处理
def requestError(date):
str = ""
if date:
str = "日期信息"
else:
str = "天气信息"

messageRobot({
"msgtype": "text",
"text": {
"content": "【闹钟提醒】"+str+"接口请求失败,注意添加闹钟!!!"
}
})

# 补假要上班
def messageWorkAbnormal(date, reason, rain):
if rain == -1:
logWithTime("getWeatherError ---> ")
requestError(False)
else:
str = ""
if rain == 1:
logWithTime("messageWorkAbnormal rain ---> ")
str = "可能下雨,需提前出门,"
logWithTime("messageWorkAbnormal ---> ")
messageRobot({
"msgtype": "text",
"text": {
"content": "【闹钟提醒】明天是:{},{} 要上班,".format(date,reason)+ str + "注意添加闹钟!!!"
}
})


# 钉钉机器人发送消息
def messageRobot(msg):
url = 'your webhook'

headers = {
'Content-Type': 'application/json'
}
requests.post(url, data=json.dumps(msg), headers=headers)

# 日志带时间利于排查
def logWithTime(msg):
localtime = time.asctime( time.localtime(time.time()) )
print (localtime + " " + msg)


if __name__ == "__main__":
getDateInfo()

每日晚上11点23在服务端运行这个脚本,请求钉钉机器人接口,给自己发送一个钉钉推送提醒,要定好闹钟

树莓派设置定时任务

将python脚本上传到树莓派,用ftp,ssh,samba都可以,这里就不详细说明了,设置定时任务前需要注意的是校准树莓派的时间,树莓派默认采用欧洲时区,如果树莓派的时间校准过,可略过此步骤。

校准树莓派时间

查看当前树莓派时间date
设置树莓派时区
sudo dpkg-reconfigure tzdata
选择亚洲时区Asia,选择上海时间Shanghai

contab设置定时任务

linux定时任务可利用contab设置,crontab -e,进入文件编辑,选择编辑工具,nano或vim
格式为:Minute Hour Day Month Dayofweek command
Minute 每个小时的第几分钟执行该任务
Hour 每天的第几个小时执行该任务
Day 每月的第几天执行该任务
Month 每年的第几个月执行该任务
DayOfWeek 每周的第几天执行该任务
Command 要执行的命令
设置23点22分执行python3的脚本,并输出日志,利于维护和问题排查
22 23 * * * python3 /home/pi/upload/test123.py >>/home/pi/mylog.log
保存文件,并重启contab服务
sudo service cron restart

ip变化提醒(2020/03/07更新)

家里申请了电信的公网ip,之前买了域名,一直在用端口转发+动态域名服务访问家里的树莓派,最近域名过期了,不打算续了,但希望仍然能在外网访问到家里的树莓派,电信的公网ip一直都是变动的,让电信固定ip需要繁杂的手续且需要企业申请,所以是不可能的了。最后想了一个可行的方法,如果能利用钉钉机器人在ip变化时给自己发送一条消息,告知最新的ip,那么仍然可以访问到家里的树莓派,这个问题就解决了。那如何知道ip变化了呢?还是利用linux的定时任务,每个小时跑一次python脚本,获取当前的外网ip,并将第一次的结果写入本地文件记录下来,每次运行脚本将当前外网ip与上一次记录的外网ip对比,如果不一致则ip变化,给自己发送钉钉消息告知最新的ip并再次写入文件刷新本地记录,如果ip一致则不用发送。

获取本机外网ip

找了一个免费的接口,http://members.3322.org/dyndns/getip,直接返回本机外网ip,如果为空就请求失败了,也给自己发送钉钉消息,及时处理,python实现如下:

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
import json
import requests
import os
import time
# 获取当前ip
def getip():
url = "http://members.3322.org/dyndns/getip"
response = requests.get(url)
logWithTime ("getip --->" + response.text)
myip = response.text.strip()
return myip

# 接口请求出错处理
def requestError():
messageRobot({
"msgtype": "text",
"text": {
"content": "【ip变更提醒】获取ip接口请求失败!!!"
}
})

def writeFile(ip):
fo = open("ip.txt", "w")
fo.write( ip)

def readFile():
fo = open("ip.txt", "r+")
return fo.read()

# 钉钉机器人发送消息
def messageRobot(msg):
url = 'your webhook'

headers = {
'Content-Type': 'application/json'
}
requests.post(url, data=json.dumps(msg), headers=headers)

# 开启ip变更通知任务
def notifyIpTask():
if os.path.exists("ip.txt"):
lastIp = readFile()
else:
lastIp = ""
logWithTime("notifyIpTask ---> notify ip server start")
myIp = getip()
if myIp == "":
logWithTime("notifyIpTask ---> get ip error")
requestError()
elif myIp == lastIp:
logWithTime("notifyIpTask ---> same ip")
else:
logWithTime("notifyIpTask ---> send ip")
writeFile(myIp)
messageRobot({
"msgtype": "text",
"text": {
"content": "【ip变更提醒】当前ip为{}".format(myIp)
}
})

# 日志带时间利于排查
def logWithTime(msg):
localtime = time.asctime( time.localtime(time.time()) )
print (localtime + " " + msg)

if __name__ == "__main__":
notifyIpTask()


最近公司有个需求,希望在不同的Android设备上实现视频的播放同步,误差尽可能的小,后期继续优化实现逐帧同步效果,以便后期进行矩阵拼接屏的研发,前期处于试探性阶段,在App上实现,目前误差在10-20ms内,属于肉眼完全看不出的误差效果范围,只有利用手机的慢动作摄影才能看出一点过渡误差,并且视频播放出的声音听起来也完全同步。对于请求主机时间消息的传递误差,主要利用了ntp时间同步技术来解决,对于视频的加载耗时通过两个播放器实例交替加载,达到预先加载的效果来解决,加载视频耗时造成的误差基本可以忽略不计。只要获取到了当前的主机时间,那么就能推算出主机的播放时间,这个时间可以看作是所有设备同步播放的时间,也能算出当前设备时间到这个同步播放时间的偏移量,达到准时播放同一个视频的效果。由于设备不一定一直联网,排除设备的时钟时间错误造成的干扰,这里统一采用rtc时间(硬件时钟)。这里主要记录一下在Android平台上ntp协议的应用。


NTP协议简介

网络时间协议NTP(Network Time Protocol)用于将计算机客户或服务器的时间与另一服务器同步,使用层次式时间分布模型。在配置时,NTP可以利用冗余服务器和多条网络路径来获得时间的高准确性和高可靠性。即使客户机在长时间无法与某一时间服务器相联系的情况下,仍可提供高准确度时间。

联网计算机同步时钟最简便的方法是网络授时。网络授时分为广域网授时和局域网授时。广域网授时精度通常能达50ms级,但有时超过500ms,这是因为每次经过的路由器路径可能不相同。现在还没有更好的办法将这种不同路径延迟的时间误差完全消除。局域网授时不存在路由器路径延迟问题,因而授时精度理论上可以提到亚毫秒级。Windows内置NTP服务,在局域网内其最高授时精度也只能达10ms级。

进一步提高NTP授时精度的方法

局域网络延相对较大的原因在于时间戳的发送请求和接收一般都是在应用层。减少操作系统内核处理延时可以提高NTP授时精度,使发/收NTP包时间戳应尽量接近主机真实发/收包时刻。在不改变硬件的条件下,修改网卡驱动程序,将记录NTP包发/收时间戳从应用程序移至网卡驱动程序处,可消除操作系统内核处理延时不确定而造成的误差。这种方法在局域网中可大幅提高NTP授时精度至μs级。

NTP的算法推导

NTP最典型的授时方式是Client/Server方式。如下图所示,客户机首先向服务器发送一个NTP包,其中包含了该包离开客户机的时间戳T1,当服务器接收到该包时,依次填入包到达的时间戳T2、包离开的时间戳T3,然后立即把包返回给客户机。客户机在接收到响应包时,记录包返回的时间戳T4。客户机用上述4个时间参数就能够计算出2个关键参数:NTP包的往返延迟d和客户机与服务器之间的时钟偏差t。客户机使用时钟偏差来调整本地时钟,以使其时间与服务器时间一致。

T1为客户发送NTP请求时间戳(以客户时间为参照);T2为服务器收到NTP请求时间戳(以服务器时间为参照);T3为服务器回复NTP请求时间戳(以服务器时间为参照);T4为客户收到NTP回复包时间戳(以客户时间为参照);d1为NTP请求包传送延时,d2为NTP回复包传送延时;t为服务器和客户端之间的时间偏差,d为NTP包的往返时间
那么服务器的处理时间为T3 - T2,服务器从发送请求到接收应答总时间为T4 - T1
例如T1客户发送NTP请求时间为10:00:00am,T2服务器收到NTP请求时间为11:00:01am,T3服务器回复NTP请求时间为11:00:02am,T4客户端收到NTP回复包10:00:03am,那么
d = (T4 - T1) - (T3 - T2) = 2秒
客户端设备相对服务端设备的时间差为
t = (T2 - T1) + (T3 - T4) / 2 约等于 1 小时

Android代码实现

客户端请求ntp时间

已知客户端T1,T4时间,实现从服务器返回的报文中获取T2,T3时间参数并计算服务器ntp时间

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
   private static final int NTP_PACKET_SIZE = 48;
private static final int NTP_MODE_CLIENT = 3;
private static final int NTP_VERSION = 3;
private static final int RECEIVE_TIME_OFFSET = 32;
private static final int TRANSMIT_TIME_OFFSET = 40;
private static final long OFFSET_1900_TO_1970 = ((365L * 70L) + 17L) * 24L * 60L * 60L;
/**
* 请求ntp服务器设备的时间,设置服务器IP地址,端口号,以及超时时间
*
* @param ipAddress ip地址
* @param port 端口号
* @param timeout 超时时间单位毫秒
* @return 如果请求成功返回true
*/
private boolean requestTime(String ipAddress, int port, int timeout) {
try {
InetAddress address = InetAddress.getByName(ipAddress);
DatagramSocket mSocket = new DatagramSocket();
mSocket.setSoTimeout(timeout);
byte[] buffer = new byte[NTP_PACKET_SIZE];
DatagramPacket request = new DatagramPacket(buffer, buffer.length, address, port);
buffer[0] = NTP_MODE_CLIENT | (NTP_VERSION << 3);
// 请求发送时间
final long requestTicks = SystemClock.elapsedRealtime();
mSocket.send(request);
// 读取服务器响应数据包
DatagramPacket response = new DatagramPacket(buffer, buffer.length);
mSocket.receive(response);
// 收到服务器响应时间
final long responseTicks = SystemClock.elapsedRealtime();
// 服务器的接收时间
final long receiveTime = readTimeStamp(buffer, RECEIVE_TIME_OFFSET);
// 服务器的发送时间
final long transmitTime = readTimeStamp(buffer, TRANSMIT_TIME_OFFSET);
// 计算时间客户端和服务器的时间差(服务器收到请求时间 - 客户端请求时间) + (服务器回复请求时间 - 客户端收到回复时间) / 2
long clockOffset = ((receiveTime - requestTicks) + (transmitTime - responseTicks)) / 2;
// 当前ntp服务器的时间
long mNtpTime = SystemClock.elapsedRealtime() + clockOffset;
// 添加到平均值计算集合中,求平均值使得计算误差更小
addDeviation(clockOffset);
} catch (Exception e) {
return false;
} finally {
try {
if (mSocket != null) {
if (!mSocket.isClosed()) {
mSocket.close();
}
mSocket.disconnect();
mSocket = null;
}
} catch (Exception e) {
e.printStackTrace();
}
}
return true;
}

/**
* 从buffer中读取32位的大端数
*/
private long read32(byte[] buffer, int offset) {
byte b0 = buffer[offset];
byte b1 = buffer[offset + 1];
byte b2 = buffer[offset + 2];
byte b3 = buffer[offset + 3];
// convert signed bytes to unsigned values
int i0 = ((b0 & 0x80) == 0x80 ? (b0 & 0x7F) + 0x80 : b0);
int i1 = ((b1 & 0x80) == 0x80 ? (b1 & 0x7F) + 0x80 : b1);
int i2 = ((b2 & 0x80) == 0x80 ? (b2 & 0x7F) + 0x80 : b2);
int i3 = ((b3 & 0x80) == 0x80 ? (b3 & 0x7F) + 0x80 : b3);

return ((long) i0 << 24) + ((long) i1 << 16) + ((long) i2 << 8)
+ (long) i3;
}

/**
* 从buffer中读取时间戳
* 返回毫秒数
*/
private long readTimeStamp(byte[] buffer, int offset) {
// 读取秒数
long seconds = read32(buffer, offset);
// 读取小数
long fraction = read32(buffer, offset + 4);
return ((seconds - OFFSET_1900_TO_1970) * 1000)
+ ((fraction * 1000L) / 0x100000000L);
}

这里的SystemClock.elapsedRealtime()是获取设备启动后到现在的时间(硬件时间),System.currentTimeMillis()获取的是系统时间(软件时间),是距离1970年1月1日开始计算的一个值,这里为了减少误差,避免因为外界修改软件时间而导致时间不准的情况而使用硬件时间。

服务器返回ntp时间

从客户端发的报文中获取响应地址和端口号,把收到请求的时间写入报文并保存到链表,从链表中取出刚才放入的报文,写入发送响应的时间发给客户端

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
private static final long OFFSET_1900_TO_1970 = ((365L * 70L) + 17L) * 24L * 60L * 60L;
private static final int RECEIVE_TIME_OFFSET = 32;
private static final int TRANSMIT_TIME_OFFSET = 40;
private static final int NTP_PACKET_SIZE = 48;
private static final int NTP_MODE_SERVIER = 0;
private static final int NTP_VERSION = 3;
public void start(int port) {
mCompositeDisposable.add(Observable.just(1).subscribeOn(Schedulers.io())
.subscribe(integer -> {
start = true;
Logger.t(TAG).d("服务端开启 port=" + port);
try {
mSocket = new DatagramSocket(null);
// 设置端口重用
mSocket.setReuseAddress(true);
// 绑定端口号
mSocket.bind(new InetSocketAddress(port));
while (start) {
try {
byte[] buffer = new byte[NTP_PACKET_SIZE];
DatagramPacket response = new DatagramPacket(buffer,
buffer.length);
buffer[0] = NTP_MODE_SERVIER | (NTP_VERSION << 3);
Logger.t(TAG).d("等待接收客户端消息");
mSocket.receive(response);
DatagramPacket echo = new DatagramPacket(buffer, buffer.length, response.getAddress(), response.getPort());
Logger.t(TAG).d("收到客户端请求=" + response.getAddress());
// 把收到的时间写入buffer
writeTimeStamp(buffer, RECEIVE_TIME_OFFSET, SystemClock.elapsedRealtime());
// 添加到链表
mLinkedList.add(echo);
} catch (IOException e) {
Logger.t(TAG).e("while receive failed:" + e);
}
}
} catch (Exception e) {
Logger.t(TAG).e("response failed:" + e);
} finally {
Logger.t(TAG).e("关闭socket");
if (mSocket != null) {
if (!mSocket.isClosed()) {
mSocket.close();
}
mSocket.disconnect();
mSocket = null;
}
}
}));
mCompositeDisposable.add(Observable.just(1).subscribeOn(Schedulers.io())
.subscribe(integer -> {
mSendSocket = new DatagramSocket();
mSendSocket.setSoTimeout(2000);
while (start) {
try {
if (mLinkedList.size() > 0) {
// 取出刚才放入链表的数据包
DatagramPacket first = mLinkedList.removeFirst();
if (first != null) {
// 写入发送响应包的时间
writeTimeStamp(first.getData(), TRANSMIT_TIME_OFFSET, SystemClock.elapsedRealtime());
Logger.t(TAG).d("向客户端发送消息=" + first.getAddress());
mSendSocket.send(first);
}
}
} catch (IOException e) {
Logger.t(TAG).e("while send failed:" + e);
}
}
Logger.t(TAG).d("发送消息端关闭");
}));
}

/**
* 写入硬件时间到buffer中
*/
private void writeTimeStamp(byte[] buffer, int offset, long time) {
long seconds = time / 1000L;
long milliseconds = time - seconds * 1000L;
seconds += OFFSET_1900_TO_1970;

// 按大端模式写入秒数
buffer[offset++] = (byte) (seconds >> 24);
buffer[offset++] = (byte) (seconds >> 16);
buffer[offset++] = (byte) (seconds >> 8);
buffer[offset++] = (byte) (seconds >> 0);

long fraction = milliseconds * 0x100000000L / 1000L;
// 按大端模式写入小数
buffer[offset++] = (byte) (fraction >> 24);
buffer[offset++] = (byte) (fraction >> 16);
buffer[offset++] = (byte) (fraction >> 8);
// 低位字节随机
buffer[offset++] = (byte) (Math.random() * 255.0);
}


家里面一直使用的MAC电脑最近遇到一个比较烦人的问题,浏览器被劫持,打开chrome浏览器和safari浏览器主页全是AnySearch的搜索导航界面,被一个名为AnySearch的插件恶意修改了主页,主页修改劫持,最开始我手动删除chrome和safari的插件,当时主页被修正回来了,但是过不了几天这个插件又悄悄的被安装在chrome和safari浏览器上,主页又被修改为了AnySearch的搜索导航界面。我发现SystemPreference(系统偏好)中多出一个profile的选项,里面能看到一个AnySearch脚本路径,然后我在profile中把脚本删除,再手动删除chrome和safari的插件,结果和第一次的尝试一样,过几天又悄无声息的装上了,主页再次被修改。我查找了很多方案都不起作用,后来发现一个比较靠谱的方法能彻底移除AnySearch的浏览器劫持,在这里记录一下。


除了删除profile中AnySearch的脚本路径和chrome浏览器以及safari浏览器的AnySearch插件以外

命令行中删除以下路径的文件

~/Library/Safari/Extensions/AnySearch.safariextz

~/Library/Saved\ Application\ State/com.apple.Safari.savedState
~/Library/Saved\ Application\ State/com.google.Chrome.savedState

2019-11-22记

仅删除文件以上无效,每过一段时间仍然会自动安装系统描述文件profile,并自动给chrome浏览器和safari浏览器安装infoSearch插件,篡改浏览器主页,拦截所有google搜索的内容到yahoo搜索,需要将~/Library/Application Support下的所有infosearch相关的文件删除。
命令行执行

1
2
cd ~/Library/Application Support
find ./ -iname "infosearch*"

找出所有相关的文件路径并删除路径下的文件


又是很长一段时间没有更新博客了,最近工作特别忙,加班多都没有时间写博客了。在如今这个快节奏的时代,难免会有这样的需求,身处办公室希望能远程下发下载任务到家里的树莓派下载资源,之前开坑玩了一下树莓派,尝试了迅雷远程下载的方案,在树莓派上搭建一个迅雷的远程下载服务器,不过目前这种方案已经失效了,迅雷关闭了远程下载的服务。不过最近我发现了一个不错的远程下载方案,aria2是一个在linux上支持远程下载的工具,无奈它的下载速度实在是太慢了,百度云的第三方下载工具PanDownload是基于aria2的,下载速度非常可观,且支持远程下载。我们可以在树莓派上搭建aria2的下载服务器,然后利用PanDownload远程下发下载任务到树莓派上的aria2服务器,即可实现远程下载。


树莓派搭建aria2服务器

关于树莓派的准备工作和系统安装这里就不讲了,希望了解的朋友可以查看上一篇,这里主要是说一下关于aria2服务器的搭建流程。

安装aria2

sudo apt install -y aria2

编写配置文件

  • 创建配置文件的文件夹
    mkdir -p ~/.config/aria2/
  • vim编写配置文件
    sudo vi ~/.config/aria2/aria2.config
    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
    #后台运行
    daemon=true
    #设置加密密钥
    rpc-secret=secret
    #允许rpc
    enable-rpc=true
    #允许所有来源, web界面跨域权限需要
    rpc-allow-origin-all=true
    #允许外部访问,false的话只监听本地端口
    rpc-listen-all=true
    #RPC端口, 仅当默认端口被占用时修改
    rpc-listen-port=6800
    #最大同时下载数(任务数), 路由建议值: 3
    max-concurrent-downloads=5
    #断点续传
    continue=true
    #同服务器连接数
    max-connection-per-server=5
    #最小文件分片大小, 下载线程数上限取决于能分出多少片, 对于小文件重要
    min-split-size=10M
    #单文件最大线程数, 路由建议值: 5
    split=10
    #下载速度限制
    max-overall-download-limit=0
    #单文件速度限制
    max-download-limit=0
    #上传速度限制
    max-overall-upload-limit=0
    #单文件速度限制
    max-upload-limit=0
    #文件保存路径
    dir=/home/pi/Downloads
    #所需时间
    file-allocation=prealloc
    #不进行证书校验
    check-certificate=false
    #保存下载会话
    save-session=/home/pi/.config/aria2/aria2.session
    input-file=/home/pi/.config/aria2/aria2.session
    #断电续传
    save-session-interval=60
  • 创建该会话空白文件
    touch /home/pi/.config/aria2/aria2.session
  • 测试下aria2是否启动成功
    aria2c --conf-path=/home/pi/.config/aria2/aria2.config
  • 是否有进程启动
    用 ps aux|grep aria2
  • 结束进程
    kill -9 xxxx

设置aria2服务并开机启动

编写服务文件

sudo vim /lib/systemd/system/aria.service

1
2
3
4
5
6
7
8
9
10
11
[Unit]
Description=Aria2 Service
After=network.target

[Service]
User=pi
Type=forking
ExecStart=/usr/bin/aria2c --conf-path=/home/pi/.config/aria2/aria2.config

[Install]
WantedBy=multi-user.target

设置开机启动

sudo systemctl daemon-reload
sudo systemctl enable aria

查看aria服务状态

sudo systemctl status aria

启动aria2服务

sudo systemctl start aria

停止aria2服务

sudo systemctl stop aria

重启aria2服务

sudo systemctl restart aria

PanDownload配置

目前PanDownload只有windows版本,mac 需开启虚拟机使用
设置 -> 远程 -> 添加 -> 添加远程主机
效果图

  1. 勾选开启远程下载模式
    效果图
  2. 填写树莓派下载服务器的ip,填写端口号对应配置文件中的rpc-listen-port和token对应配置文件中的rpc-secret
    效果图
  3. 检测连接,提示连接成功
    效果图
  4. 取消默认下载路径的配置,这样才可以在选择下载文件后弹出远程下载的选择弹窗
  5. 如果提示“下载失败:无法创建文件,给下载文件夹授权,chmod 777 /mnt/download