HBuilderX

HBuilderX

极客开发工具
uni-app

uni-app

开发一次,多端覆盖
uniCloud

uniCloud

云开发平台
HTML5+

HTML5+

增强HTML5的功能体验
MUI

MUI

上万Star的前端框架

地点搜索功能分享,用来弥补uniapp 使用uni.chooseLocation无法选择其他城市地点的不足

自己在使用uni.chooseLocation时发现只能搜索到当前城市的地点,无法搜索到其他城市的地点,这样导致实现地点搜索时限制很多,因此分享给出自己结合HTML5+实现的地点选择。

<template>  
    <view class="full-wrap">  
        <view id="map">  
        </view>  
        <view class="now-pos" @tap="searchPos(posCity)">  
            定位城市:{{posCity}}  
        </view>  
        <view class="choose-city flex-box">  
            <view class="city-item flex-box">  
                <text class="city flex-box" @tap="chooseCity">{{nowCity}}</text>  
                <image src="../../static/down.png" class="down-ico"></image>  
            </view>  
            <view class="search-box">  
                <input placeholder="搜索地点" type="text" name="input" @input="searchPos(nowCity)" style="width: 100% ;" v-model="searchText"></input>  
            </view>  
        </view>  
        <view class="search-result-wrap">  
            <view class="cu-item padding" :key="index" v-for="(item,index) in cityList" @click="chooseHandle(item)">  
                <view class="search-result-content">  
                    <text class="list-city-name">{{item.name}}</text>  
                    <text class="list-city-address text-gray">{{item.city+item.address}}</text>  
                </view>  
            </view>  
        </view>  
        <!-- 城市选择 该插件从插件市场下载 -->  
        <mpvue-picker :themeColor="themeColor" ref="mpvuePicker" :mode="mode" :deepLength="deepLength" :pickerValueDefault="pickerValueDefault"  
         @onConfirm="onConfirm" @="" :pickerValueArray="pickerValueArray"></mpvue-picker>  
    </view>  
</template>  

<script>  
    import cityData from '../../commonjs/city.data.js'  
    import mpvuePicker from '../../components/mpvue-picker/mpvuePicker.vue'  
    export default {  
        components: {  
            mpvuePicker  
        },  
        data() {  
            return {  
                lat: " 39.915",  
                lng: '116.404',  
                posCity: "",  
                nowCity: '',  
                searchText: "",  
                cityList: [],  
                pickerValueDefault: [0, 0],  
                themeColor: '#007AFF',  
                mode: 'multiLinkageSelector',  
                deepLength: 2,  
                pickerValueArray: cityData  
            }  
        },  
        mounted() {  
            // 默认以当前位置为中心  
            uni.getLocation({  
                geocode: 'true',  
                success: res => {  
                    this.posCity = res.address.city;  
                    this.nowCity = res.address.city;  
                    this.lat = res.latitude;  
                    this.lng = res.longitude;  
                    this.searchPos(res.address.city);  
                }  
            });  
        },  
        methods: {  
            searchPos(cityName) {  
                let map = this.$refs.map;  
                let searchObj = new plus.maps.Search(map);  
                searchObj.onPoiSearchComplete = (state, result)=> {  
                    if (state == 0) {  
                        if (result.currentNumber <= 0) {  
                            uni.showToast({  
                                title: '没有检索到结果',  
                                icon: 'none'  
                            });  
                        }  
                        this.cityList = [];  
                        for (let i = 0; i < result.currentNumber; i++) {  
                            let pos = result.getPosition(i);  
                            this.cityList.push(pos);  
                        }  
                    } else {  
                        uni.showToast({  
                            title: '检索失败',  
                            icon: 'none'  
                        });  
                    }  
                }  
                let pt = new plus.maps.Point(this.lng, this.lat);  
                // 默认搜索火车站  
                let nowPosCIty = this.searchText ? this.searchText : '火车站';  
                searchObj.poiSearchInCity(cityName, nowPosCIty);  
            },  
            chooseCity() {  
                this.$refs.mpvuePicker.show();  
            },  
            onCancel(e) {  
                console.log(e);  
            },  
            onConfirm(e) {  
                let pickerText = e.label.split('-');  
                this.searchPos(pickerText[1])  
            },  
            chooseHandle(e) {  
                console.log('选择地点的地点信息:');  
                console.log(e);  
            }  
        }  
    }  
</script>  

<style scoped>  
    #map1 {  
        height: 0;  
    }  
    .flex-box {  
        display: flex;  
    }  
.now-pos {  
    font-size: 30upx;  
    padding: 25upx;  
}  
    .choose-city {  
        background-color: #FFFFFF;  
        border-bottom: 0.5px solid #ddd;  
    }  

    .city-item {  
        width: 160upx;  
        color: grey;  
        padding: 20upx 15upx;  
        margin-left: 15upx;  
        border-right: 0.5px solid #ddd;  
        border-radius: 5upx;  
    }  

    .city {  
        font-size: 30upx;  
        margin-top: 8upx;  
    }  
.down-ico {  
    width: 30upx;  
    height: 30upx;  
    position: relative;  
    top:16upx;  
}  
    .search-result-wrap {  
        padding: 10upx;  
    }  

    .search-result-content {  
        display: flex;  
        flex-direction: column;  
        padding: 10upx;  
        border-bottom: 0.5px solid #ddd;  
    }  

    .search-box {  
        font-size: 30upx;  
        width: 550upx;  
        padding: 20upx 15upx;  
        background-color: #FFFFFF;  
    }  

    .list-city-name {  
        font-size: 32upx;  
        margin-bottom: 0;  
        padding: 0;  
    }  

    .list-city-address {  
        font-size: 24upx;  
        color: gray;  
        margin-top: 0;  
    }  
</style>  

其中使用了城市选择插件,结合自己的需要自行选择,我直接在插件市场找了一个使用了,如有侵权,请联系本人QQ:1414901782;
演示视频:见附件
项目地址:uniapp选择地点

继续阅读 »

自己在使用uni.chooseLocation时发现只能搜索到当前城市的地点,无法搜索到其他城市的地点,这样导致实现地点搜索时限制很多,因此分享给出自己结合HTML5+实现的地点选择。

<template>  
    <view class="full-wrap">  
        <view id="map">  
        </view>  
        <view class="now-pos" @tap="searchPos(posCity)">  
            定位城市:{{posCity}}  
        </view>  
        <view class="choose-city flex-box">  
            <view class="city-item flex-box">  
                <text class="city flex-box" @tap="chooseCity">{{nowCity}}</text>  
                <image src="../../static/down.png" class="down-ico"></image>  
            </view>  
            <view class="search-box">  
                <input placeholder="搜索地点" type="text" name="input" @input="searchPos(nowCity)" style="width: 100% ;" v-model="searchText"></input>  
            </view>  
        </view>  
        <view class="search-result-wrap">  
            <view class="cu-item padding" :key="index" v-for="(item,index) in cityList" @click="chooseHandle(item)">  
                <view class="search-result-content">  
                    <text class="list-city-name">{{item.name}}</text>  
                    <text class="list-city-address text-gray">{{item.city+item.address}}</text>  
                </view>  
            </view>  
        </view>  
        <!-- 城市选择 该插件从插件市场下载 -->  
        <mpvue-picker :themeColor="themeColor" ref="mpvuePicker" :mode="mode" :deepLength="deepLength" :pickerValueDefault="pickerValueDefault"  
         @onConfirm="onConfirm" @="" :pickerValueArray="pickerValueArray"></mpvue-picker>  
    </view>  
</template>  

<script>  
    import cityData from '../../commonjs/city.data.js'  
    import mpvuePicker from '../../components/mpvue-picker/mpvuePicker.vue'  
    export default {  
        components: {  
            mpvuePicker  
        },  
        data() {  
            return {  
                lat: " 39.915",  
                lng: '116.404',  
                posCity: "",  
                nowCity: '',  
                searchText: "",  
                cityList: [],  
                pickerValueDefault: [0, 0],  
                themeColor: '#007AFF',  
                mode: 'multiLinkageSelector',  
                deepLength: 2,  
                pickerValueArray: cityData  
            }  
        },  
        mounted() {  
            // 默认以当前位置为中心  
            uni.getLocation({  
                geocode: 'true',  
                success: res => {  
                    this.posCity = res.address.city;  
                    this.nowCity = res.address.city;  
                    this.lat = res.latitude;  
                    this.lng = res.longitude;  
                    this.searchPos(res.address.city);  
                }  
            });  
        },  
        methods: {  
            searchPos(cityName) {  
                let map = this.$refs.map;  
                let searchObj = new plus.maps.Search(map);  
                searchObj.onPoiSearchComplete = (state, result)=> {  
                    if (state == 0) {  
                        if (result.currentNumber <= 0) {  
                            uni.showToast({  
                                title: '没有检索到结果',  
                                icon: 'none'  
                            });  
                        }  
                        this.cityList = [];  
                        for (let i = 0; i < result.currentNumber; i++) {  
                            let pos = result.getPosition(i);  
                            this.cityList.push(pos);  
                        }  
                    } else {  
                        uni.showToast({  
                            title: '检索失败',  
                            icon: 'none'  
                        });  
                    }  
                }  
                let pt = new plus.maps.Point(this.lng, this.lat);  
                // 默认搜索火车站  
                let nowPosCIty = this.searchText ? this.searchText : '火车站';  
                searchObj.poiSearchInCity(cityName, nowPosCIty);  
            },  
            chooseCity() {  
                this.$refs.mpvuePicker.show();  
            },  
            onCancel(e) {  
                console.log(e);  
            },  
            onConfirm(e) {  
                let pickerText = e.label.split('-');  
                this.searchPos(pickerText[1])  
            },  
            chooseHandle(e) {  
                console.log('选择地点的地点信息:');  
                console.log(e);  
            }  
        }  
    }  
</script>  

<style scoped>  
    #map1 {  
        height: 0;  
    }  
    .flex-box {  
        display: flex;  
    }  
.now-pos {  
    font-size: 30upx;  
    padding: 25upx;  
}  
    .choose-city {  
        background-color: #FFFFFF;  
        border-bottom: 0.5px solid #ddd;  
    }  

    .city-item {  
        width: 160upx;  
        color: grey;  
        padding: 20upx 15upx;  
        margin-left: 15upx;  
        border-right: 0.5px solid #ddd;  
        border-radius: 5upx;  
    }  

    .city {  
        font-size: 30upx;  
        margin-top: 8upx;  
    }  
.down-ico {  
    width: 30upx;  
    height: 30upx;  
    position: relative;  
    top:16upx;  
}  
    .search-result-wrap {  
        padding: 10upx;  
    }  

    .search-result-content {  
        display: flex;  
        flex-direction: column;  
        padding: 10upx;  
        border-bottom: 0.5px solid #ddd;  
    }  

    .search-box {  
        font-size: 30upx;  
        width: 550upx;  
        padding: 20upx 15upx;  
        background-color: #FFFFFF;  
    }  

    .list-city-name {  
        font-size: 32upx;  
        margin-bottom: 0;  
        padding: 0;  
    }  

    .list-city-address {  
        font-size: 24upx;  
        color: gray;  
        margin-top: 0;  
    }  
</style>  

其中使用了城市选择插件,结合自己的需要自行选择,我直接在插件市场找了一个使用了,如有侵权,请联系本人QQ:1414901782;
演示视频:见附件
项目地址:uniapp选择地点

收起阅读 »

在uni-app中使用pdf.js预览 pdf文件

分享 uniapp pdf

主要根据 掘金上的这篇博文 跨平台(uni-app)文件在线预览解决方案 设置,但是遇到了几个坑,和大家分享下:

  • 出现跨域问题
    可以将 view.js 的
       if (origin !== viewerOrigin && protocol !== 'blob:') {  
        throw new Error('file origin does not match viewer\'s');  
      }

注释掉

  • 无法读取文件流
    uniapp一读取文件流就闪退。可以下载后读取。但是下载后按照原方法,会被读取view.js下的路径,需要用H5+的api转换成绝对路径
        onLoad() {  
            uni.downloadFile({  
                url: 'http://192.168.18.28:8280/webupload/service/sys/file/upload/download?appKey=accessKey&prodID=prodID&fileID=3494049602848768',  
                success: (result) => {  
                    var tempFilePath = result.tempFilePath  
                    let fileUrl = plus.io.convertLocalFileSystemURL(tempFilePath)  
                    this.allUrl = this.viewerUrl + '?file=' + fileUrl  
                }  
            })  
        }
继续阅读 »

主要根据 掘金上的这篇博文 跨平台(uni-app)文件在线预览解决方案 设置,但是遇到了几个坑,和大家分享下:

  • 出现跨域问题
    可以将 view.js 的
       if (origin !== viewerOrigin && protocol !== 'blob:') {  
        throw new Error('file origin does not match viewer\'s');  
      }

注释掉

  • 无法读取文件流
    uniapp一读取文件流就闪退。可以下载后读取。但是下载后按照原方法,会被读取view.js下的路径,需要用H5+的api转换成绝对路径
        onLoad() {  
            uni.downloadFile({  
                url: 'http://192.168.18.28:8280/webupload/service/sys/file/upload/download?appKey=accessKey&prodID=prodID&fileID=3494049602848768',  
                success: (result) => {  
                    var tempFilePath = result.tempFilePath  
                    let fileUrl = plus.io.convertLocalFileSystemURL(tempFilePath)  
                    this.allUrl = this.viewerUrl + '?file=' + fileUrl  
                }  
            })  
        }
收起阅读 »

APICloud终于承认侵权并向DCloud道歉了(附最新产品对比)

APICloud

​在北京市高级人民法院的法令要求下,APICloud终于承认了侵犯DCloud著作权,并在官网和公众号向DCloud道歉。

2015年,DCloud起诉APICloud侵权后,APICloud不但否认侵权,还污蔑DCloud在造谣,在开发者和媒体处混淆视听。如今,这份迟来了4年的道歉函,终于让真相大白!

但令人遗憾的是,与海底捞等诚信企业的真挚道歉相比,APICloud的道歉行为缺少诚意。

首先从表达形式上,这篇道歉函堪称反SEO经典教材。

  1. 网页以图片方式显示标题,防止爬虫抓取
  2. 链接:https://www.apicloud.com/static_pages/qz.html,不能直接访问,必须从APICloud官网首页点那个图片来跳转
  3. 禁止搜索引擎爬虫访问(robots:noindex,nofollow)
  4. 道歉函页面没有title
  5. 道歉函源码没有一句正文,所有内容使用js编写

网友纷纷表示学习了,这反SEO做的太用心了。
有这份心思,花在正道上,岂不是更好?

道歉形式权当调侃玩笑了。但DCloud不能接收的,是道歉内容本身,完全看不出APICloud真正反思了自己的错误,看不出它的道歉诚意。
下为APICloud道歉函正文:

注:apicloud官网的致歉函已经下线,可在微信公众号搜索“apicloud”,查询2019年12月3日的历史文章,了解致歉函内容。

DCloud已通过法律途径和微信公众号明确提出了我们对致歉内容的意见,但APICloud仍拒不更改致歉函。

在此,DCloud再次公开提出我们的3点道歉要求:

  • 要求1

APICloud持续多个版本破解DCloud产品、抄袭源码、盗用DLL文件和图片。在APICloud的致歉函中,不能以“开发人员的不慎”,轻描淡写的敷衍过去了。
破解反编译产品是费时费力的事情,我们很难相信普通开发人员,在没有领导的安排下,会长时间去做这么冒险的事情。管理层应该反思自己,而不是甩锅给不知名的开发人员。

  • 要求2

在DCloud起诉APICloud后,APICloud反而污蔑DCloud在造谣,数次中伤DCloud声誉、讽刺DCloud管理层。APICloud需收回自己的错误言论,并为此向DCloud道歉。

  • 要求3

APICloud误导、欺瞒开发者,使开发者做出错误判断,APICloud不仅应该向DCloud道歉,也应该向被误导和欺瞒的开发者道歉。

如果APICloud真正认识到自己的错误,请认真反思,回应DCloud的3点要求,重新给出一份有诚意的致歉函。

后记

在DCloud发出本文的公众号后,不少开发者在后台留言:

  • 有人依然为侵权行为做掩护,并嘲笑使用正版软件的人虚伪。
  • 有不太了解DCloud产品,希望详细了解产品区别。

前者我们懒得理他,并且不欢迎他进入DCloud社区。
后者的问题,我们在此整理一下最新版比较

  1. 产品覆盖
    DCloud有HBuilder和uni-app两个产品,HBuilder是前端开发工具,有400多万开发者,月活百万;uni-app是多端应用开发框架,有手机端月活高达8.4亿。
    可以详细了解DCloud的案例,很多知名开发商:https://uniapp.dcloud.io/case
    使用uni-app,可以同时开发跨平台App、H5网页,以及微信/阿里/百度/头条/QQ等小程序。

    • 技术人员掌握uni-app,可以完成老板交代的任何项目
    • 开发商使用uni-app,可以大幅提升开发效率,降低开发成本,获得更多用户
      而apicloud只能开发App,无法覆盖其他平台。
  2. 发展趋势
    从百度指数可以看出,apicloud这些年基本在原地踏步

    iOS13发布,UIWebview被列为私有API,苹果强推Apple登录。DCloud第一时间提供了wkwebview白屏恢复等各种方案,以及Apple登录。
    Android10发布,无法获取IMEI等设备信息。DCloud及时提供了OAID等业内新标准。
    而APICloud落后几个月才参考DCloud方案更新版本,维护力度很弱。

  3. 收费
    APICloud有大量收费点,详见:https://www.apicloud.com/vipservice/price
    一个应用装机量超5万收费、打渠道包收费、打包总次数收费、创建应用多了收费、一个应用多人协作收费...到处是收费点。基本策略就是初期免费引用户进来,应用做大就会开始收费。
    而DCloud全免费,不管你的应用做到多大,哪怕用户过亿,DCloud的产品也仍然是免费的。并且以后也永远不会做这种养韭菜、割韭菜的事情,永不变更目前的免费策略。
    有人说DCloud云打包也收费,并非如此。只是经受过恶意流量攻击,DCloud的云打包会控制每日的不合理打包次数,正常使用肯定不会收费。而且DCloud支持完全免费的离线打包,更自由。
    除了上述收费点,APICloud把大量重要功能从基础产品中剥离,放到插件市场销售。
    比如原生视频、直播,比如跨多家手机厂商的统一推送,DCloud均包含在基础产品中免费提供,而APICloud要单独购买插件。
    开发一个应用,使用APICloud要比DCloud付出更多成本。

  4. App端的性能体验
    uni-app支持nvue,使用纯原生渲染,性能体验、内存占用优于竞品更多。
    即便是使用uni-app的webview渲染,由于它是一个小程序引擎,体验更加优秀,不管是新页面加载速度,还是Android手机输入框弹出的感受,都和原生一样顺滑。
    uni-app自带一个独立的js引擎,运行更快,可以直接运行所有es6语法,而不需要转换适配低版本WebView。
    包括软键盘弹出等很多细节体验,apicloud都做不细做不好。

  5. 生态
    DCloud的周边插件和培训生态丰富。
    DCloud的插件市场虽然推出较晚,但目前已有一千多款插件。详见:https://ext.dcloud.net.cn/
    DCloud基础产品的功能较多,支持Native.js调用各种手机端API,支持丰富的小程序SDK直接在App端使用,又有各种丰富的原生插件,形成了非常完善的插件生态。
    uni-app还支持小程序生态的组件、sdk在app里直接使用,通过renderjs支持web组件生态库在app端的使用,可以说汇聚了最多的生态力度,可以找到各种优秀轮子。
    uni-app插件市场优秀的原生插件作者,月收入可过万。
    插件市场还有大量行业应用模板,如电商、电子书、漫画、音乐等整套免费模板,可拿来就用。
    开发者在DCloud的生态中寻找轮子,不说应有尽有,但也更加丰富,而且更加便宜、ui定制更灵活。
    vue的一个重要创新,就是组件化。uni-app将其发扬光大,还新增了easycom技术,市场中各种组件可以极快的完成应用的搭建。
    而uni-app的培训生态也非常完善,腾讯课堂官方为uni-app录制视频教程,腾讯课堂、网易云课堂、哔哩哔哩中有各种收费或免费的视频教程。

  6. 社区
    DCloud的问答社区,月活百万,每日几百篇帖子。QQ/微信群,累计70多个,其中uni-app有几十个2千人大群,每日几十万聊天记录。
    在csdn、掘金等社区也有大量开发者的经验分享。
    在更活跃、更开放的社区里,开发者可以快速进步。
    DCloud官方也投入大量精力,听取社区反馈意见,每月积极更新产品,持续帮助开发者更加成功。

uni-app当然也还不完美,官方也在持续升级。但整体而言,比某些竞品要更优秀、更良心。
DCloud无意垄断市场,欢迎竞争。开发者可以在DCloud、react native、flutter等各种优秀的跨平台开发工具中选择,但不要被某些并无诚意的产品蒙蔽。

继续阅读 »

​在北京市高级人民法院的法令要求下,APICloud终于承认了侵犯DCloud著作权,并在官网和公众号向DCloud道歉。

2015年,DCloud起诉APICloud侵权后,APICloud不但否认侵权,还污蔑DCloud在造谣,在开发者和媒体处混淆视听。如今,这份迟来了4年的道歉函,终于让真相大白!

但令人遗憾的是,与海底捞等诚信企业的真挚道歉相比,APICloud的道歉行为缺少诚意。

首先从表达形式上,这篇道歉函堪称反SEO经典教材。

  1. 网页以图片方式显示标题,防止爬虫抓取
  2. 链接:https://www.apicloud.com/static_pages/qz.html,不能直接访问,必须从APICloud官网首页点那个图片来跳转
  3. 禁止搜索引擎爬虫访问(robots:noindex,nofollow)
  4. 道歉函页面没有title
  5. 道歉函源码没有一句正文,所有内容使用js编写

网友纷纷表示学习了,这反SEO做的太用心了。
有这份心思,花在正道上,岂不是更好?

道歉形式权当调侃玩笑了。但DCloud不能接收的,是道歉内容本身,完全看不出APICloud真正反思了自己的错误,看不出它的道歉诚意。
下为APICloud道歉函正文:

注:apicloud官网的致歉函已经下线,可在微信公众号搜索“apicloud”,查询2019年12月3日的历史文章,了解致歉函内容。

DCloud已通过法律途径和微信公众号明确提出了我们对致歉内容的意见,但APICloud仍拒不更改致歉函。

在此,DCloud再次公开提出我们的3点道歉要求:

  • 要求1

APICloud持续多个版本破解DCloud产品、抄袭源码、盗用DLL文件和图片。在APICloud的致歉函中,不能以“开发人员的不慎”,轻描淡写的敷衍过去了。
破解反编译产品是费时费力的事情,我们很难相信普通开发人员,在没有领导的安排下,会长时间去做这么冒险的事情。管理层应该反思自己,而不是甩锅给不知名的开发人员。

  • 要求2

在DCloud起诉APICloud后,APICloud反而污蔑DCloud在造谣,数次中伤DCloud声誉、讽刺DCloud管理层。APICloud需收回自己的错误言论,并为此向DCloud道歉。

  • 要求3

APICloud误导、欺瞒开发者,使开发者做出错误判断,APICloud不仅应该向DCloud道歉,也应该向被误导和欺瞒的开发者道歉。

如果APICloud真正认识到自己的错误,请认真反思,回应DCloud的3点要求,重新给出一份有诚意的致歉函。

后记

在DCloud发出本文的公众号后,不少开发者在后台留言:

  • 有人依然为侵权行为做掩护,并嘲笑使用正版软件的人虚伪。
  • 有不太了解DCloud产品,希望详细了解产品区别。

前者我们懒得理他,并且不欢迎他进入DCloud社区。
后者的问题,我们在此整理一下最新版比较

  1. 产品覆盖
    DCloud有HBuilder和uni-app两个产品,HBuilder是前端开发工具,有400多万开发者,月活百万;uni-app是多端应用开发框架,有手机端月活高达8.4亿。
    可以详细了解DCloud的案例,很多知名开发商:https://uniapp.dcloud.io/case
    使用uni-app,可以同时开发跨平台App、H5网页,以及微信/阿里/百度/头条/QQ等小程序。

    • 技术人员掌握uni-app,可以完成老板交代的任何项目
    • 开发商使用uni-app,可以大幅提升开发效率,降低开发成本,获得更多用户
      而apicloud只能开发App,无法覆盖其他平台。
  2. 发展趋势
    从百度指数可以看出,apicloud这些年基本在原地踏步

    iOS13发布,UIWebview被列为私有API,苹果强推Apple登录。DCloud第一时间提供了wkwebview白屏恢复等各种方案,以及Apple登录。
    Android10发布,无法获取IMEI等设备信息。DCloud及时提供了OAID等业内新标准。
    而APICloud落后几个月才参考DCloud方案更新版本,维护力度很弱。

  3. 收费
    APICloud有大量收费点,详见:https://www.apicloud.com/vipservice/price
    一个应用装机量超5万收费、打渠道包收费、打包总次数收费、创建应用多了收费、一个应用多人协作收费...到处是收费点。基本策略就是初期免费引用户进来,应用做大就会开始收费。
    而DCloud全免费,不管你的应用做到多大,哪怕用户过亿,DCloud的产品也仍然是免费的。并且以后也永远不会做这种养韭菜、割韭菜的事情,永不变更目前的免费策略。
    有人说DCloud云打包也收费,并非如此。只是经受过恶意流量攻击,DCloud的云打包会控制每日的不合理打包次数,正常使用肯定不会收费。而且DCloud支持完全免费的离线打包,更自由。
    除了上述收费点,APICloud把大量重要功能从基础产品中剥离,放到插件市场销售。
    比如原生视频、直播,比如跨多家手机厂商的统一推送,DCloud均包含在基础产品中免费提供,而APICloud要单独购买插件。
    开发一个应用,使用APICloud要比DCloud付出更多成本。

  4. App端的性能体验
    uni-app支持nvue,使用纯原生渲染,性能体验、内存占用优于竞品更多。
    即便是使用uni-app的webview渲染,由于它是一个小程序引擎,体验更加优秀,不管是新页面加载速度,还是Android手机输入框弹出的感受,都和原生一样顺滑。
    uni-app自带一个独立的js引擎,运行更快,可以直接运行所有es6语法,而不需要转换适配低版本WebView。
    包括软键盘弹出等很多细节体验,apicloud都做不细做不好。

  5. 生态
    DCloud的周边插件和培训生态丰富。
    DCloud的插件市场虽然推出较晚,但目前已有一千多款插件。详见:https://ext.dcloud.net.cn/
    DCloud基础产品的功能较多,支持Native.js调用各种手机端API,支持丰富的小程序SDK直接在App端使用,又有各种丰富的原生插件,形成了非常完善的插件生态。
    uni-app还支持小程序生态的组件、sdk在app里直接使用,通过renderjs支持web组件生态库在app端的使用,可以说汇聚了最多的生态力度,可以找到各种优秀轮子。
    uni-app插件市场优秀的原生插件作者,月收入可过万。
    插件市场还有大量行业应用模板,如电商、电子书、漫画、音乐等整套免费模板,可拿来就用。
    开发者在DCloud的生态中寻找轮子,不说应有尽有,但也更加丰富,而且更加便宜、ui定制更灵活。
    vue的一个重要创新,就是组件化。uni-app将其发扬光大,还新增了easycom技术,市场中各种组件可以极快的完成应用的搭建。
    而uni-app的培训生态也非常完善,腾讯课堂官方为uni-app录制视频教程,腾讯课堂、网易云课堂、哔哩哔哩中有各种收费或免费的视频教程。

  6. 社区
    DCloud的问答社区,月活百万,每日几百篇帖子。QQ/微信群,累计70多个,其中uni-app有几十个2千人大群,每日几十万聊天记录。
    在csdn、掘金等社区也有大量开发者的经验分享。
    在更活跃、更开放的社区里,开发者可以快速进步。
    DCloud官方也投入大量精力,听取社区反馈意见,每月积极更新产品,持续帮助开发者更加成功。

uni-app当然也还不完美,官方也在持续升级。但整体而言,比某些竞品要更优秀、更良心。
DCloud无意垄断市场,欢迎竞争。开发者可以在DCloud、react native、flutter等各种优秀的跨平台开发工具中选择,但不要被某些并无诚意的产品蒙蔽。

收起阅读 »

uniapp做一个前端,大概30个页面,最好是个人开发者。


可以加微信详谈,样式只是大概,样式全部使用colorui拼接,不求优化适配全部设备。只求速度上线开发,能做的我们细谈。


可以加微信详谈,样式只是大概,样式全部使用colorui拼接,不求优化适配全部设备。只求速度上线开发,能做的我们细谈。

个推 实现个推绑定、解绑别名。。。

uni-app极光推送:https://blog.csdn.net/dashenid/article/details/103437459

由于html5+没有提供绑定、解绑别名的方法,

用native.js去实现,点用原生的个推方法

igexinTool.js

function igexinTool() {  
    var isAndorid, PushManager, context, Instance, GeTuiSdk;  

    if(plus.os.name == 'Android') {  
        isAndorid = true;  
    } else {  
        isAndorid = false;  
    }  

    if(isAndorid) {  
        PushManager = plus.android.importClass("com.igexin.sdk.PushManager");  
        context = plus.android.runtimeMainActivity().getContext();  
        Instance = PushManager.getInstance();  
    } else {  
        GeTuiSdk = plus.ios.importClass("GeTuiSdk");  
    }  

    this.bindAlias = function(alias) {  
        if(isAndorid) {  
            Instance.bindAlias(context, alias);  
        } else {  
            GeTuiSdk.bindAliasandSequenceNum(alias, alias);  
        }  
    }  

    this.unbindAlias = function(alias) {  
        if(isAndorid) {  
            Instance.unBindAlias(context, alias, true);  
        } else {  
            GeTuiSdk.unbindAliasandSequenceNumandIsSelf(alias, alias, true);  
        }  
    }  

    this.getVersion = function() {  
        if(isAndorid) {  
            return Instance.getVersion(context);  
        } else {  
            return GeTuiSdk.version;  
        }  
    }  

    //开启推送  
    this.turnOnPush = function() {  
        if(isAndorid) {  
            Instance.turnOnPush(context);  
        } else {  
            GeTuiSdk.setPushModeForOff(false);  
        }  
    }  

    //关闭推送  
    this.turnOffPush = function() {  
        if(isAndorid) {  
            Instance.turnOffPush(context);  
        } else {  
            GeTuiSdk.setPushModeForOff(true);  
        }  
    }  

}

使用:
var tool = new igexinTool();
tool.bindAlias("ykj");

继续阅读 »

uni-app极光推送:https://blog.csdn.net/dashenid/article/details/103437459

由于html5+没有提供绑定、解绑别名的方法,

用native.js去实现,点用原生的个推方法

igexinTool.js

function igexinTool() {  
    var isAndorid, PushManager, context, Instance, GeTuiSdk;  

    if(plus.os.name == 'Android') {  
        isAndorid = true;  
    } else {  
        isAndorid = false;  
    }  

    if(isAndorid) {  
        PushManager = plus.android.importClass("com.igexin.sdk.PushManager");  
        context = plus.android.runtimeMainActivity().getContext();  
        Instance = PushManager.getInstance();  
    } else {  
        GeTuiSdk = plus.ios.importClass("GeTuiSdk");  
    }  

    this.bindAlias = function(alias) {  
        if(isAndorid) {  
            Instance.bindAlias(context, alias);  
        } else {  
            GeTuiSdk.bindAliasandSequenceNum(alias, alias);  
        }  
    }  

    this.unbindAlias = function(alias) {  
        if(isAndorid) {  
            Instance.unBindAlias(context, alias, true);  
        } else {  
            GeTuiSdk.unbindAliasandSequenceNumandIsSelf(alias, alias, true);  
        }  
    }  

    this.getVersion = function() {  
        if(isAndorid) {  
            return Instance.getVersion(context);  
        } else {  
            return GeTuiSdk.version;  
        }  
    }  

    //开启推送  
    this.turnOnPush = function() {  
        if(isAndorid) {  
            Instance.turnOnPush(context);  
        } else {  
            GeTuiSdk.setPushModeForOff(false);  
        }  
    }  

    //关闭推送  
    this.turnOffPush = function() {  
        if(isAndorid) {  
            Instance.turnOffPush(context);  
        } else {  
            GeTuiSdk.setPushModeForOff(true);  
        }  
    }  

}

使用:
var tool = new igexinTool();
tool.bindAlias("ykj");

收起阅读 »

uni-app 使用axios 分享 ,解决: adapter is not a function

uni-app 使用axios 真机会提示: adapter is not a function 。我也不懂,我也不会,就菜鸟一个。

然后跟了下问题所在,好像调用不到xhr.js ,
翻到这里 var adapter = config.adapter || defaults.adapter; 报错。

参考:
https://juejin.im/post/5dbd8c64e51d4529fc3facd4
https://blog.csdn.net/u013704958/article/details/90713386 我借鉴5楼提供的方法,用uni.request 来调用
https://ext.dcloud.net.cn/plugin?id=930

添加了一段代码,重新引用
//真机获取
axios.defaults.adapter = function (config) {....}

下面axios封装来自大佬的 HzyAdmin 项目
https://gitee.com/hzy6/HzyAdminSpa

import axios from 'axios';  
import qs from 'qs';  
import tools from './tools';  

let loading;  
let isloading = true;  
//http request 拦截器  
axios.interceptors.request.use(config => {  
        if (isloading) {  
            uni.showLoading();  
            loading=true;  
        }  
        var cookie = uni.getStorageSync('Authorization');  
        config.headers['x-requested-width'] = 'XMLHttpRequest';  
        config.headers['Authorization'] = cookie;  
        config.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';  

        if (!config.data) return config;  

        if (config.data.isUpload)  
            config.headers['Content-Type'] = 'multipart/form-data';  
        else  
            config.data = qs.stringify(config.data); //如果是非上传类型 则 将数据重新组装  

        return config;  
    },  
    error => {  
        console.log(error);  
        return Promise.reject(error);  
    });  

//http response 拦截器  
axios.interceptors.response.use(response => {  
        if (loading) {  
            uni.hideLoading();  
            loading=false;  
        }  
        var data = response.data;  

        if (data.hasOwnProperty('status')) {  
            // public enum EMessageBoxStatus  
            // {  
            //     接口授权码无效 = -3,  
            //     服务端异常 = -2,  
            //     自定义 = -1,  
            //     失败 = 0,  
            //     成功 = 1,  
            // }  

            if (data.status == -3) { //接口授权码无效global.$router.push("/Login")  
                tools.alert(data.msg + '请重新登录!', () => uni.navigateTo({url:'../pages/Login'}));  
                return;  
            }  
            if (data.status == -2) { //服务端异常  
                tools.alert(data.msg);  
                return;  
            }  
            if (data.status == 0) { //失败  
                tools.msg(data.msg, '错误');  
                return;  
            }  
        }  

        return response;  
    },  
    error => {  
        console.log(error);  
        uni.hideLoading();  
        if (error.response.status === 401) {  
            if (loading) {  
                loading=false;  
            }  
            global.tools.notice("无权访问!", "错误");  
            return uni.navigateTo({url:'../pages/Login'}) //global.$router.push('/Login');  
        } else {  
            return Promise.reject(error)  
        }  
    });  

//真机获取  
axios.defaults.adapter = function (config) {  
    return new Promise((resolve, reject) => {  
        console.log(config)  
        var settle = require('axios/lib/core/settle');  
        var buildURL = require('axios/lib/helpers/buildURL');  
        uni.request({  
            method: config.method.toUpperCase(),  
            url: buildURL(config.url, config.params, config.paramsSerializer),  
            header: config.headers,  
            data: config.data,  
            dataType: config.dataType,  
            responseType: config.responseType,  
            sslVerify: config.sslVerify,  
            complete:function complete(response){  
                response = {  
                  data: response.data,  
                  status: response.statusCode,  
                  errMsg: response.errMsg,  
                  header: response.header,  
                  config: config  
                };  

            settle(resolve, reject, response);  
            }  
        })  
    })  
}  

/**  
 * 封装get方法  
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @param headers 头部信息  
 * @returns {Promise}  
 */  
export function get(url, data = {}, loading = true, config = {}) {  
    isloading = loading;  
    url =`${url}?${qs.stringify(data)}`;  
    return new Promise((resolve, reject) => {  
        axios.get(url, config)  
            .then(response => {  
                resolve(response);  
            })  
            .catch(err => {  
                reject(err)  
            })  
    })  
}  

/**  
 * 封装post请求  
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @param config config信息  
 * @returns {Promise}  
 */  
export function post(url, data = {}, loading = true, config = {}) {  
    isloading = loading;  
    return new Promise((resolve, reject) => {  
        axios.post(url, data, config)  
            .then(response => {  
                if (response != undefined) {  
                    resolve(response);  
                }  
            }, err => {  
                reject(err)  
            })  
    })  
}  

/**  
 * 封装 post 请求 用于上传文件   
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @param config config信息  
 * @returns {Promise}  
 */  
export function upload(url, data = {}, loading = true, config = {}) {  
    isloading = loading;  
    if (!data) data = {};  
    data.isUpload = true;  
    return new Promise((resolve, reject) => {  
        axios.post(url, data, config)  
            .then(response => {  
                if (response != undefined) {  
                    resolve(response);  
                }  
            }, err => {  
                reject(err)  
            })  
    })  
}  

/**  
 * 封装 get请求 用于下载文件  
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @returns {Promise}  
 */  
export function download(url, data = {}, loading = true) {  
    this.get(url, data, loading, {  
        // responseType: 'stream',  
        responseType: 'blob',  
        // responseType: 'arraybuffer',  
    }).then(res => {  
        var data = res.data;  
        var headers = res.headers;  
        //"attachment; filename=6a9c13bc-e214-44e4-8456-dbca9fcd2367.xls;filename*=UTF-8''6a9c13bc-e214-44e4-8456-dbca9fcd2367.xls"  
        var contentDisposition = headers['content-disposition'];  
        var contentType = headers['content-type'];  
        var attachmentInfoArrary = contentDisposition.split(';');  
        var fileName = '';  
        if (attachmentInfoArrary.length > 1) {  
            fileName = attachmentInfoArrary[1].split('=')[1];  
        }  
        var blob = new Blob([data], { type: contentType });  

        if (window.navigator && window.navigator.msSaveOrOpenBlob) { // IE  
            window.navigator.msSaveOrOpenBlob(blob, fileName);  
        } else {  
            let url = (window.URL || window.webkitURL).createObjectURL(blob);  
            // window.open(url, "_blank"); //下载  
            // window.URL.revokeObjectURL(url) // 只要映射存在,Blob就不能进行垃圾回收,因此一旦不再需要引用,就必须小心撤销URL,释放掉blob对象。  

            let a = document.createElement('a');  
            a.style.display = 'none';  
            a.href = url;  
            a.setAttribute('download', fileName);  
            document.body.appendChild(a);  
            a.click()  
            document.body.removeChild(a); // 下载完成移除元素  
            // window.location.href = url  
            window.URL.revokeObjectURL(url); // 只要映射存在,Blob就不能进行垃圾回收,因此一旦不再需要引用,就必须小心撤销URL,释放掉blob对象。  

        }  
    });  
}  

/**  
 * 封装patch请求  
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @param config config信息  
 * @returns {Promise}  
 */  
export function patch(url, data = {}, loading = true, config = {}) {  
    isloading = loading;  
    return new Promise((resolve, reject) => {  
        axios.patch(url, data, config)  
            .then(response => {  
                resolve(response);  
            }, err => {  
                reject(err)  
            })  
    })  
}  

/**  
 * 封装put请求  
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @param config config信息  
 * @returns {Promise}  
 */  
export function put(url, data = {}, loading = true, config = {}) {  
    isloading = loading;  
    return new Promise((resolve, reject) => {  
        axios.put(url, data, config)  
            .then(response => {  
                resolve(response);  
            }, err => {  
                reject(err)  
            })  
    })  
}
继续阅读 »

uni-app 使用axios 真机会提示: adapter is not a function 。我也不懂,我也不会,就菜鸟一个。

然后跟了下问题所在,好像调用不到xhr.js ,
翻到这里 var adapter = config.adapter || defaults.adapter; 报错。

参考:
https://juejin.im/post/5dbd8c64e51d4529fc3facd4
https://blog.csdn.net/u013704958/article/details/90713386 我借鉴5楼提供的方法,用uni.request 来调用
https://ext.dcloud.net.cn/plugin?id=930

添加了一段代码,重新引用
//真机获取
axios.defaults.adapter = function (config) {....}

下面axios封装来自大佬的 HzyAdmin 项目
https://gitee.com/hzy6/HzyAdminSpa

import axios from 'axios';  
import qs from 'qs';  
import tools from './tools';  

let loading;  
let isloading = true;  
//http request 拦截器  
axios.interceptors.request.use(config => {  
        if (isloading) {  
            uni.showLoading();  
            loading=true;  
        }  
        var cookie = uni.getStorageSync('Authorization');  
        config.headers['x-requested-width'] = 'XMLHttpRequest';  
        config.headers['Authorization'] = cookie;  
        config.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';  

        if (!config.data) return config;  

        if (config.data.isUpload)  
            config.headers['Content-Type'] = 'multipart/form-data';  
        else  
            config.data = qs.stringify(config.data); //如果是非上传类型 则 将数据重新组装  

        return config;  
    },  
    error => {  
        console.log(error);  
        return Promise.reject(error);  
    });  

//http response 拦截器  
axios.interceptors.response.use(response => {  
        if (loading) {  
            uni.hideLoading();  
            loading=false;  
        }  
        var data = response.data;  

        if (data.hasOwnProperty('status')) {  
            // public enum EMessageBoxStatus  
            // {  
            //     接口授权码无效 = -3,  
            //     服务端异常 = -2,  
            //     自定义 = -1,  
            //     失败 = 0,  
            //     成功 = 1,  
            // }  

            if (data.status == -3) { //接口授权码无效global.$router.push("/Login")  
                tools.alert(data.msg + '请重新登录!', () => uni.navigateTo({url:'../pages/Login'}));  
                return;  
            }  
            if (data.status == -2) { //服务端异常  
                tools.alert(data.msg);  
                return;  
            }  
            if (data.status == 0) { //失败  
                tools.msg(data.msg, '错误');  
                return;  
            }  
        }  

        return response;  
    },  
    error => {  
        console.log(error);  
        uni.hideLoading();  
        if (error.response.status === 401) {  
            if (loading) {  
                loading=false;  
            }  
            global.tools.notice("无权访问!", "错误");  
            return uni.navigateTo({url:'../pages/Login'}) //global.$router.push('/Login');  
        } else {  
            return Promise.reject(error)  
        }  
    });  

//真机获取  
axios.defaults.adapter = function (config) {  
    return new Promise((resolve, reject) => {  
        console.log(config)  
        var settle = require('axios/lib/core/settle');  
        var buildURL = require('axios/lib/helpers/buildURL');  
        uni.request({  
            method: config.method.toUpperCase(),  
            url: buildURL(config.url, config.params, config.paramsSerializer),  
            header: config.headers,  
            data: config.data,  
            dataType: config.dataType,  
            responseType: config.responseType,  
            sslVerify: config.sslVerify,  
            complete:function complete(response){  
                response = {  
                  data: response.data,  
                  status: response.statusCode,  
                  errMsg: response.errMsg,  
                  header: response.header,  
                  config: config  
                };  

            settle(resolve, reject, response);  
            }  
        })  
    })  
}  

/**  
 * 封装get方法  
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @param headers 头部信息  
 * @returns {Promise}  
 */  
export function get(url, data = {}, loading = true, config = {}) {  
    isloading = loading;  
    url =`${url}?${qs.stringify(data)}`;  
    return new Promise((resolve, reject) => {  
        axios.get(url, config)  
            .then(response => {  
                resolve(response);  
            })  
            .catch(err => {  
                reject(err)  
            })  
    })  
}  

/**  
 * 封装post请求  
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @param config config信息  
 * @returns {Promise}  
 */  
export function post(url, data = {}, loading = true, config = {}) {  
    isloading = loading;  
    return new Promise((resolve, reject) => {  
        axios.post(url, data, config)  
            .then(response => {  
                if (response != undefined) {  
                    resolve(response);  
                }  
            }, err => {  
                reject(err)  
            })  
    })  
}  

/**  
 * 封装 post 请求 用于上传文件   
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @param config config信息  
 * @returns {Promise}  
 */  
export function upload(url, data = {}, loading = true, config = {}) {  
    isloading = loading;  
    if (!data) data = {};  
    data.isUpload = true;  
    return new Promise((resolve, reject) => {  
        axios.post(url, data, config)  
            .then(response => {  
                if (response != undefined) {  
                    resolve(response);  
                }  
            }, err => {  
                reject(err)  
            })  
    })  
}  

/**  
 * 封装 get请求 用于下载文件  
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @returns {Promise}  
 */  
export function download(url, data = {}, loading = true) {  
    this.get(url, data, loading, {  
        // responseType: 'stream',  
        responseType: 'blob',  
        // responseType: 'arraybuffer',  
    }).then(res => {  
        var data = res.data;  
        var headers = res.headers;  
        //"attachment; filename=6a9c13bc-e214-44e4-8456-dbca9fcd2367.xls;filename*=UTF-8''6a9c13bc-e214-44e4-8456-dbca9fcd2367.xls"  
        var contentDisposition = headers['content-disposition'];  
        var contentType = headers['content-type'];  
        var attachmentInfoArrary = contentDisposition.split(';');  
        var fileName = '';  
        if (attachmentInfoArrary.length > 1) {  
            fileName = attachmentInfoArrary[1].split('=')[1];  
        }  
        var blob = new Blob([data], { type: contentType });  

        if (window.navigator && window.navigator.msSaveOrOpenBlob) { // IE  
            window.navigator.msSaveOrOpenBlob(blob, fileName);  
        } else {  
            let url = (window.URL || window.webkitURL).createObjectURL(blob);  
            // window.open(url, "_blank"); //下载  
            // window.URL.revokeObjectURL(url) // 只要映射存在,Blob就不能进行垃圾回收,因此一旦不再需要引用,就必须小心撤销URL,释放掉blob对象。  

            let a = document.createElement('a');  
            a.style.display = 'none';  
            a.href = url;  
            a.setAttribute('download', fileName);  
            document.body.appendChild(a);  
            a.click()  
            document.body.removeChild(a); // 下载完成移除元素  
            // window.location.href = url  
            window.URL.revokeObjectURL(url); // 只要映射存在,Blob就不能进行垃圾回收,因此一旦不再需要引用,就必须小心撤销URL,释放掉blob对象。  

        }  
    });  
}  

/**  
 * 封装patch请求  
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @param config config信息  
 * @returns {Promise}  
 */  
export function patch(url, data = {}, loading = true, config = {}) {  
    isloading = loading;  
    return new Promise((resolve, reject) => {  
        axios.patch(url, data, config)  
            .then(response => {  
                resolve(response);  
            }, err => {  
                reject(err)  
            })  
    })  
}  

/**  
 * 封装put请求  
 * @param url  
 * @param data  
 * @param loading 是否有加载效果  
 * @param config config信息  
 * @returns {Promise}  
 */  
export function put(url, data = {}, loading = true, config = {}) {  
    isloading = loading;  
    return new Promise((resolve, reject) => {  
        axios.put(url, data, config)  
            .then(response => {  
                resolve(response);  
            }, err => {  
                reject(err)  
            })  
    })  
}
收起阅读 »

打包时未添加OAuth模块

Appstore

真是服了uniapp了,我的应用里不需要第三方登录,所有打包时没有勾选OAuth模块,结果打包时总是提示“未添加OAuth模块”,最坑爹的是打包成IOS的appstore版本也会出现这个提示,导致我的app被拒,真坑爹。

真是服了uniapp了,我的应用里不需要第三方登录,所有打包时没有勾选OAuth模块,结果打包时总是提示“未添加OAuth模块”,最坑爹的是打包成IOS的appstore版本也会出现这个提示,导致我的app被拒,真坑爹。

APP可视化埋点大揭秘,附源码解析

一、背景

运营者能够对用户行为进行分析的前提,是对大量数据的掌握。在以往,这个数据通常是由开发者在控件点击、页面等事件中,一行行地编写埋点代码来完成数据收集的。然而传统的操作模式每当升级改版时,开发和测试人员就需要重复不断对代码进行更新,整个流程耗时长,无法满足业务的需求。

为帮助开发者解决这一痛点,个推应用统计“个数”推出“可视化埋点”这一技术来更高效地实现这个这一过程。“个数”的可视化埋点灵活、方便,开发者不需对数据追踪点添加任何代码,只需要连接管理台并圈选页面中需要埋点的元素,即可添加随时生效的界面追踪点。

本文将结合个数实践经验,对可视化埋点中的两大关键技术点即控件唯一标识和事件采集进行分析并提供解决方案。

二、可视化埋点关键技术点

可视化埋点的难点,或者说核心就是如何在开发者不编写任何代码的情况下,SDK 如何确定任意一个控件在该应用内的唯一性,以及如何监听控件的点击和页面的切换。

标识

为了防止不同页面中的控件标识重复出现,控件的唯一标识一般由页面标识加上控件标识生成。

页面标识生成

页面标识可以直接使用页面的名称,即 Activity name。其获取方式比较多,这里介绍一种比较通用的方法,即通过注册 Application.ActivityLifecycleCallbacks ,开发者可以在以下生命周期的回调中,轻松地拿到当前的 Activity 对象。此方法适用于一个 Activity 并无 Fragment 存在的情形。

代码详见下图:

获取方式也是比较多,不过较于 Activity 的获取会相对麻烦一些,因为系统没有直接提供 API ,因而需要稍微转个弯:通过 Gradle 插桩的方式,获取 Fragment 的生命周期,以及 Fragment 实例对象:

如果该应用的页面存在一个 Activity 中嵌套多个 Fragment 的情况,单单一个 Activity name则可能无法精准地定位到某个页面,因而还需要加上 Fragment 的名称。Fragment的获取可以通过 Gradle 插桩法来实现,即根据 Fragment 的生命周期来获取Fragment 实例对象。

1.2控件标识生成

理想的情况下页面中的每个控件都有属于自己的唯一 id,SDK 直接获取控件的 id 当做控件标识即可。但现实情况却是,一个页面中往往存在多个相同 id 的控件,或者是没有 id 的控件,比如 Listview 的 item ,开发者不可能给listview的每个item 设置不同的 id。

因此需要转变一下思路。我们可以从控件路径这个除id 外比较独特的性质着手来生成控件标识。开发者可以通过给控件的路径加上控件角标的结构方式,生成控件的唯一标识。下图是Github 上一个仿 B 站的应用。我们对这个应用进行一下控件树分析。首先我们使用 Android Studio 自带的 UI Automator Viewer 工具查看该页面的布局结构:

接下来,我们可以从Application.ActivityLifecycleCallbacks 的回调中拿到 Activity 实例,再使用 activity.getWindow().getDecorView().getRootView() 方法来获取当前页面的控件树。

例如图中的文字控件是 TextView,且无兄弟布局,则可以标记为 TextView[0] 。它的父布局是 LinearLayout 且排在兄弟布局中的第二位,那么就可以写成是 LinearLayout[1],然后使用自己定义的符号拼接,像是 LinearLayout[1]/TextView[0] 。之后以此类推、循环遍历、层层递进,将所有经过的控件以及它们的下标都拼接起来,组成控件在该页面中的唯一标识。

对于一些可复用的 View ,我们则需要采取一些特殊处理。例如对于 RecyclerView、ListView、 ViewPager 等复用控件,我们都需要采取不同的处理方式,去获取当前 View 在该控件中的具体下标。如果没有进行特殊处理,则会导致子控件错位,数据统计不准确。

采集

在以往的处理中,如果需要知道一个按钮的点击次数,开发者就要在该控件的click事件中加入对应的打点代码。这种重复劳作,无疑增加了开发者的开发负担。对此,我们可以采用动态代理方式或Gradle 插桩方式来改善这个问题。

动态代理方式

使用安卓自带的辅助功能 View.AccessibilityDelegate 。前文提到当页面变化时,我们可以通过 Application.ActivityLifecycleCallbacks 获取到 Activity 的实例对象,接着根据activity.getWindow().getDecorView().getRootView() 来获取到控件树。由于控件树可能会实时发生变化,我们则需要通过 ViewTreeObserver.OnGlobalLayoutListener 的方法监听视图变化,从而在该回调中拿到变化的控件。接着我们 要根据递归判断该控件是否为 ViewGroup、是否可以点击、是否能够显示等,继而给符合条件的 View 设置 sendAccessibilityEvent();此外,我们还要在继承了 View.AccessibilityDelegate 的定义类中,对以下这些方法添加 SDK 的代理:

当对应的控件被点击时,系统就会自动调用设置过代理的方法,存储或者上报对应数据。

Gradle 插桩的方式

Android Gradle 工具在1.5.0 版本后提供了 Transfrom API , 该API 允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件。在编译期,开发者可以通过onClick、onItemClick等方法(详见下图)进行监听,这相当于是正则匹配。

当上述监听的方法被编译的时候,就可以将埋点的代理操作插入这些方法中,实现自动化埋点的流程。网上相关流程也是非常详细,有兴趣的可以自行搜索学习。

三、结语

以上就是APP端可视化埋点实现过程中的关键点,特别需要注意的是控件唯一标识那一块,由于布局千变万化,开发者针对很多特定的布局都需要采取对应的处理方式。

目前个推应用统计——个数这个产品只需要一行初始化代码就可以自动帮助开发者采集包括页面统计、事件埋点、新增活跃等多维度信息。

借着万圣节,悄悄跟你说个恐怖的故事:除个推应用统计服务之外,VIP消息推送、用户画像,现在申请均可以免费用一年!一键认证服务还可以享受充10万条送5万条的优惠哦 !点击https://www.getui.com/2019devfest,“码“上申请吧!

行业前沿、面试宝典,更多技术干货,尽在个推技术学院。

继续阅读 »

一、背景

运营者能够对用户行为进行分析的前提,是对大量数据的掌握。在以往,这个数据通常是由开发者在控件点击、页面等事件中,一行行地编写埋点代码来完成数据收集的。然而传统的操作模式每当升级改版时,开发和测试人员就需要重复不断对代码进行更新,整个流程耗时长,无法满足业务的需求。

为帮助开发者解决这一痛点,个推应用统计“个数”推出“可视化埋点”这一技术来更高效地实现这个这一过程。“个数”的可视化埋点灵活、方便,开发者不需对数据追踪点添加任何代码,只需要连接管理台并圈选页面中需要埋点的元素,即可添加随时生效的界面追踪点。

本文将结合个数实践经验,对可视化埋点中的两大关键技术点即控件唯一标识和事件采集进行分析并提供解决方案。

二、可视化埋点关键技术点

可视化埋点的难点,或者说核心就是如何在开发者不编写任何代码的情况下,SDK 如何确定任意一个控件在该应用内的唯一性,以及如何监听控件的点击和页面的切换。

标识

为了防止不同页面中的控件标识重复出现,控件的唯一标识一般由页面标识加上控件标识生成。

页面标识生成

页面标识可以直接使用页面的名称,即 Activity name。其获取方式比较多,这里介绍一种比较通用的方法,即通过注册 Application.ActivityLifecycleCallbacks ,开发者可以在以下生命周期的回调中,轻松地拿到当前的 Activity 对象。此方法适用于一个 Activity 并无 Fragment 存在的情形。

代码详见下图:

获取方式也是比较多,不过较于 Activity 的获取会相对麻烦一些,因为系统没有直接提供 API ,因而需要稍微转个弯:通过 Gradle 插桩的方式,获取 Fragment 的生命周期,以及 Fragment 实例对象:

如果该应用的页面存在一个 Activity 中嵌套多个 Fragment 的情况,单单一个 Activity name则可能无法精准地定位到某个页面,因而还需要加上 Fragment 的名称。Fragment的获取可以通过 Gradle 插桩法来实现,即根据 Fragment 的生命周期来获取Fragment 实例对象。

1.2控件标识生成

理想的情况下页面中的每个控件都有属于自己的唯一 id,SDK 直接获取控件的 id 当做控件标识即可。但现实情况却是,一个页面中往往存在多个相同 id 的控件,或者是没有 id 的控件,比如 Listview 的 item ,开发者不可能给listview的每个item 设置不同的 id。

因此需要转变一下思路。我们可以从控件路径这个除id 外比较独特的性质着手来生成控件标识。开发者可以通过给控件的路径加上控件角标的结构方式,生成控件的唯一标识。下图是Github 上一个仿 B 站的应用。我们对这个应用进行一下控件树分析。首先我们使用 Android Studio 自带的 UI Automator Viewer 工具查看该页面的布局结构:

接下来,我们可以从Application.ActivityLifecycleCallbacks 的回调中拿到 Activity 实例,再使用 activity.getWindow().getDecorView().getRootView() 方法来获取当前页面的控件树。

例如图中的文字控件是 TextView,且无兄弟布局,则可以标记为 TextView[0] 。它的父布局是 LinearLayout 且排在兄弟布局中的第二位,那么就可以写成是 LinearLayout[1],然后使用自己定义的符号拼接,像是 LinearLayout[1]/TextView[0] 。之后以此类推、循环遍历、层层递进,将所有经过的控件以及它们的下标都拼接起来,组成控件在该页面中的唯一标识。

对于一些可复用的 View ,我们则需要采取一些特殊处理。例如对于 RecyclerView、ListView、 ViewPager 等复用控件,我们都需要采取不同的处理方式,去获取当前 View 在该控件中的具体下标。如果没有进行特殊处理,则会导致子控件错位,数据统计不准确。

采集

在以往的处理中,如果需要知道一个按钮的点击次数,开发者就要在该控件的click事件中加入对应的打点代码。这种重复劳作,无疑增加了开发者的开发负担。对此,我们可以采用动态代理方式或Gradle 插桩方式来改善这个问题。

动态代理方式

使用安卓自带的辅助功能 View.AccessibilityDelegate 。前文提到当页面变化时,我们可以通过 Application.ActivityLifecycleCallbacks 获取到 Activity 的实例对象,接着根据activity.getWindow().getDecorView().getRootView() 来获取到控件树。由于控件树可能会实时发生变化,我们则需要通过 ViewTreeObserver.OnGlobalLayoutListener 的方法监听视图变化,从而在该回调中拿到变化的控件。接着我们 要根据递归判断该控件是否为 ViewGroup、是否可以点击、是否能够显示等,继而给符合条件的 View 设置 sendAccessibilityEvent();此外,我们还要在继承了 View.AccessibilityDelegate 的定义类中,对以下这些方法添加 SDK 的代理:

当对应的控件被点击时,系统就会自动调用设置过代理的方法,存储或者上报对应数据。

Gradle 插桩的方式

Android Gradle 工具在1.5.0 版本后提供了 Transfrom API , 该API 允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件。在编译期,开发者可以通过onClick、onItemClick等方法(详见下图)进行监听,这相当于是正则匹配。

当上述监听的方法被编译的时候,就可以将埋点的代理操作插入这些方法中,实现自动化埋点的流程。网上相关流程也是非常详细,有兴趣的可以自行搜索学习。

三、结语

以上就是APP端可视化埋点实现过程中的关键点,特别需要注意的是控件唯一标识那一块,由于布局千变万化,开发者针对很多特定的布局都需要采取对应的处理方式。

目前个推应用统计——个数这个产品只需要一行初始化代码就可以自动帮助开发者采集包括页面统计、事件埋点、新增活跃等多维度信息。

借着万圣节,悄悄跟你说个恐怖的故事:除个推应用统计服务之外,VIP消息推送、用户画像,现在申请均可以免费用一年!一键认证服务还可以享受充10万条送5万条的优惠哦 !点击https://www.getui.com/2019devfest,“码“上申请吧!

行业前沿、面试宝典,更多技术干货,尽在个推技术学院。

收起阅读 »

4000 余字为你讲透 Codis 内部工作原理

一、引言
Codis是一个分布式 Redis 解决方案,可以管理数量巨大的Redis节点。个推作为专业的第三方推送服务商,多年来专注于为开发者提供高效稳定的消息推送服务。每天通过个推平台下发的消息数量可达百亿级别。基于个推推送业务对数据量、并发量以及速度的要求非常高,实践发现,单个Redis节点性能容易出现瓶颈,综合考虑各方面因素后,我们选择了Codis来更好地管理和使用Redis。

二、选择Codis的原因
随着公司业务规模的快速增长,我们对数据量的存储需求也越来越大,实践表明,在单个Redis的节点实例下,高并发、海量的存储数据很容易使内存出现暴涨。

此外,每一个Redis的节点,其内存也是受限的,主要有以下两个原因:

一是内存过大,在进行数据同步时,全量同步的方式会导致时间过长,从而增加同步失败的风险;
二是越来越多的redis节点将导致后期巨大的维护成本。

因此,我们对Twemproxy、Codis和Redis Cluster 三种主流redis节点管理的解决方案进行了深入调研。

推特开源的Twemproxy最大的缺点是无法平滑的扩缩容。而Redis Cluster要求客户端必须支持cluster协议,使用Redis Cluster需要升级客户端,这对很多存量业务是很大的成本。此外,Redis Cluster的p2p方式增加了通信成本,且难以获知集群的当前状态,这无疑增加了运维的工作难度。

而豌豆荚开源的Codis不仅可以解决Twemproxy扩缩容的问题,而且兼容了Twemproxy,且在Redis Cluster(Redis官方集群方案)漏洞频出的时候率先成熟稳定下来,所以最后我们使用了Codis这套集群解决方案来管理数量巨大的redis节点。

目前个推在推送业务上综合使用Redis和Codis,小业务线使用Redis,数据量大、节点个数众多的业务线使用Codis。

我们要清晰地理解Codis内部是如何工作的,这样才能更好地保证Codis集群的稳定运行。下面我们将从Codis源码的角度来分析Codis的Dashboard和Proxy是如何工作的。

三、Codis介绍
Codis是一个代理中间件,用GO语言开发而成。Codis 在系统的位置如下图所示 :

Codis是一个分布式Redis解决方案,对于上层应用来说,连接Codis Proxy和连接原生的Redis Server没有明显的区别,有部分命令不支持;

Codis底层会处理请求的转发、不停机的数据迁移等工作,对于前面的客户端来说,Codis是透明的,可以简单地认为客户端(client)连接的是一个内存无限大的Redis服务。

Codis分为四个部分,分别是:
Codis Proxy (codis-proxy)
Codis Dashboard
Codis Redis (codis-server)
ZooKeeper/Etcd

Codis架构

四、Dashboard的内部工作原理

Dashboard介绍
Dashboard是Codis的集群管理工具,所有对集群的操作包括proxy和server的添加、删除、数据迁移等都必须通过dashboard来完成。Dashboard的启动过程是对一些必要的数据结构以及对集群的操作的初始化。

Dashboard启动过程
Dashboard启动过程,主要分为New()和Start()两步。

New()阶段
⭕ 启动时,首先读取配置文件,填充config信息。coordinator的值如果是"zookeeper"或者是"etcd",则创建一个zk或者etcd的客户端。根据config创建一个Topom{}对象。Topom{}十分重要,该对象里面存储了集群中某一时刻所有的节点信息(slot,group,server等),而New()方法会给Topom{}对象赋值。

⭕ 随后启动18080端口,监听、处理对应的api请求。

⭕ 最后启动一个后台线程,每隔一分钟清理pool中无效client。

下图是dashboard在New()时内存中对应的数据结构。


Start()阶段

⭕ Start()阶段,将内存中model.Topom{}写入zk,路径是/codis3/codis-demo/topom。

⭕ 设置topom.online=true。

⭕ 随后通过Topom.store从zk中重新获取最新的slotMapping、group、proxy等数据填充到topom.cache中(topom.cache,这个缓存结构,如果为空就通过store从zk中取出slotMapping、proxy、group等信息并填充cache。不是只有第一次启动的时候cache会为空,如果集群中的元素(server、slot等等)发生变化,都会调用dirtyCache,将cache中的信息置为nil,这样下一次就会通过Topom.store从zk中重新获取最新的数据填充。)

⭕ 最后启动4个goroutine for循环来处理相应的动作 。

创建group过程
创建分组的过程很简单。
⭕ 首先,我们通过Topom.store从zk中重新拉取最新的slotMapping、group、proxy等数据填充到topom.cache中。

⭕ 然后根据内存中的最新数据来做校验:校验group的id是否已存在以及该id是否在1~9999这个范围内。

⭕ 接着在内存中创建group{}对象,调用zkClient创建路径/codis3/codis-demo/group/group-0001。

初始,这个group下面是空的。
{
"id": 1,
"servers": [],
"promoting": {},
"out_of_sync": false
}

添加codis server
⭕接下来,向group中添加codis server。Dashboard首先会去连接后端codis server,判断节点是否正常。

⭕ 接着在codis server上执行slotsinfo命令,如果命令执行失败则会导致cordis server添加进程的终结。

⭕ 之后,通过Topom.store从zk中重新拉取最新的slotMapping、group、proxy等数据填充到topom.cache中,根据内存中的最新数据来做校验,判断当前group是否在做主从切换,如果是,则退出;然后检查group server在zk中是否已经存在。

⭕ 最后,创建一个groupServer{}对象,写入zk。
当codis server添加成功后,就像我们上面说的,Topom{}在Start时,有4个goroutine for循环,其中RefreshRedisStats()就可以将codis server的连接放进topom.stats.redisp.pool中




tips
⭕ Topom{}在Start时,有4个goroutine for循环,其中RefreshRedisStats执行过程中会将codis server的连接放进topom.stats.redisp.pool中;

⭕ RefreshRedisStats()每秒执行一次,里面的逻辑是从topom.cache中获取所有的codis server,然后根据codis server的addr 去topom.stats.redisp.Pool.pool 里面获取client。如果能取到,则执行info命令;如果不能取到,则新建一个client,放进pool中,然后再使用client执行info命令,并将info命令执行的结果放进topom.stats.servers中。

Codis Server主从同步
当一个group添加完成2个节点后,要点击主从同步按钮,将第二个节点变成第一个的slave节点。

⭕ 首先,第一步还是刷新topom.cache。我们通过Topom.store从zk中重新获取最新的slotMapping、group、proxy等数据并把它们填充到topom.cache中。

⭕然后根据最新的数据进行判断:group.Promoting.State != models.ActionNothing,说明当前group的Promoting不为空,即 group里面的两个cordis server在做主从切换,主从同步失败;

group.Servers[index].Action.State == models.ActionPending,说明当前作为salve角色的节点,其状态为pending,主从同步失败;

⭕ 判断通过后,获取所有codis server状态为ActionPending的最大的action.index的值+1,赋值给当前的codis server,然后设置当前作为slave角色的节点的状态为:g.Servers[index].Action.State = models.ActionPending。将这些信息写进zk。

⭕ Topom{}在Start时,有4个goroutine for循环,其中一个用于具体处理主从同步问题。

⭕ 页面上点击主从同步按钮后,内存中对应的数据结构会发生相应的变化:

⭕ 写进zk中的group信息:


tips

Topom{}在Start时,有4个goroutine for循环,其中一个便用于具体来处理主从同步。具体怎么做呢?

首先,通过Topom.store从zk中重新获取最新的slotMapping、group、proxy等数据填充到topom.cache中,待得到最新的cache数据后,获取需要做主从同步的group server,修改group.Servers[index].Action.State == models.ActionSyncing,写入zk中。

其次,dashboard连接到作为salve角色的节点上,开启一个redis事务,执行主从同步命令:

c.Send(“MULTI”) —> 开启事务
c.Send(“config”, “set”, “masterauth”, c.Auth)
c.Send(“slaveof”, host, port)

c.Send(“config”, “rewrite")
c.Send(“client”, “kill”, “type”, “normal")
c.Do(“exec”) —> 事物执行

⭕ 主从同步命令执行完成后,修改group.Servers[index].Action.State == “synced”并将其写入zk中。至此,整个主从同步过程已经全部完成。

codis server在做主从同步的过程中,从开始到完成一共会经历5种状态:

""(ActionNothing) --> 新添加的codis,没有主从关系的时候,状态为空
pending(ActionPending) --> 页面点击主从同步之后写入zk中
syncing(ActionSyncing) --> 后台goroutine for循环处理主从同步时,写入zk的中间状态
synced --> goroutine for循环处理主从同步成功后,写入zk中的状态
synced_failed --> goroutine for循环处理主从同步失败后,写入zk中的状态

slot分配
上文给Codis集群添加了codis server,做了主从同步,接下来我们把1024个slot分配给每个codis server。Codis给使用者提供了多种方式,它可以将指定序号的slot移到某个指定group,也可以将某个group中的多个slot移动到另一个group。不过,最方便的方式是自动rebalance。

通过Topom.store我们首先从zk中重新获取最新的slotMapping、group、proxy等数据填充到topom.cache中,再根据cache中最新的slotMapping和group信息,生成slots分配计划 plans = {0:1, 1:1, … , 342:3, …, 512:2, …, 853:2, …, 1023:3},其中key 为 slot id, value 为 group id。接着,我们按照slots分配计划,更新slotMapping信息:Action.State = ActionPending和Action.TargetId = slot分配到的目标group id,并将更新的信息写回zk中。

Topom{}在Start时,有4个goroutine for循环,其中一个用于处理slot分配。

SlotMapping:



tips
● Topom{}在Start时,有4个goroutine for循环,其中ProcessSlotAction执行过程中就将codis server的连接放进topom.action.redisp.pool中了。

● ProcessSlotAction()每秒执行一次,待里面的一系列处理逻辑执行之后,它会从topom{}.action.redisp.Pool.pool中获取client,随后在redis上执行SLOTSMGRTTAGSLOT命令。如果client能取到,则dashboard会在redis上执行迁移命令;如果不能取到,则新建一个client,放进pool中,然后再使用client执行迁移命令。

SlotMapping中action对应的7种状态:

我们知道Codis是由ZooKeeper来管理的,当Codis的Codis Dashbord改变槽位信息时,其他的Codis Proxy节点会监听到ZooKeeper的槽位变化,并及时同步槽位信息。


总结一下,启动dashboard过程中,需要连接zk、创建Topom这个struct,并通过18080这个端口与集群进行交互,然后将该端口收到的信息进行转发。此外,还需要启动四个goroutine、刷新集群中的redis和proxy的状态,以及处理slot和同步操作。

五、Proxy的内部工作原理

proxy启动过程
proxy启动过程,主要分为New()、Online()、reinitProxy()和接收客户端请求()等4个环节。

New()阶段
⭕ 首先,在内存中新建一个Proxy{}结构体对象,并进行各种赋值。
⭕ 其次,启动11080端口和19000端口。
⭕ 然后启动3个goroutine后台线程,处理对应的操作:
●Proxy启动一个goroutine后台线程,并对11080端口的请求进行处理;
●Proxy启动一个goroutine后台线程,并对19000端口的请求进行处理;
●Proxy启动一个goroutine后台线程,通过ping codis server对后端bc予以维护 。





Online()阶段
⭕ 首先对model.Proxy{}的id进行赋值,Id = ctx.maxProxyId() + 1。若添加第一个proxy时, ctx.maxProxyId() = 0,则第一个proxy的id 为 0 + 1。

⭕ 其次,在zk中创建proxy目录。

⭕之后,对proxy内存数据进行刷新reinitProxy(ctx, p, c)。

⭕ 第四,设置如下代码:
online = true
proxy.online = true
router.online = true
jodis.online = true

⭕ 第五,zk中创建jodis目录。


reinitProxy()
⭕Dashboard从zk[m1] 中重新获取最新的slotMapping、group、proxy等数据填充到topom.cache中。根据cache中的slotMapping和group数据,Proxy可以得到model.Slot{},其里面包含了每个slot对应后端的ip与port。建立每个codis server的连接,然后将连接放进router中。

⭕ Redis请求是由sharedBackendConn中取出的一个BackendConn进行处理的。Proxy.Router中存储了集群中所有sharedBackendConnPool和slot的对应关系,用于将redis的请求转发给相应的slot进行处理,而Router里面的sharedBackendConnPool和slot则是通过reinitProxy()来保持最新的值。

总结一下proxy启动过程中的流程。首先读取配置文件,获取Config对象。其次,根据Config新建Proxy,并填充Proxy的各个属性。这里面比较重要的是填充models.Proxy(详细信息可以在zk中查看),以及与zk连接、注册相关路径。

随后,启动goroutine监听11080端口的codis集群发过来的请求并进行转发,以及监听发到19000端口的redis请求并进行相关处理。紧接着,刷新zk中数据到内存中,根据models.SlotMapping和group在Proxy.router中创建1024个models.Slot。此过程中Router为每个Slot都分配了对应的backendConn,用于将redis请求转发给相应的slot进行处理。

六、Codis内部原理补充说明
Codis中key的分配算法是先把key进行CRC32,得到一个32位的数字,然后再hash%1024后得到一个余数。这个值就是这个key对应着的槽,这槽后面对应着的就是redis的实例。

slot共有七种状态:nothing(用空字符串表示)、pending、preparing、prepared、migrating、finished。

如何保证slots在迁移过程中不影响客户端的业务?
⭕ client端把命令发送到proxy, proxy会算出key对应哪个slot,比如30,然后去proxy的router里拿到Slot{},内含backend.bc和migrate.bc。如果migrate.bc有值,说明slot目前在做迁移操作,系统会取出migrate.bc.conn(后端codis-server连接),并在codis server上强制将这个key迁移到目标group,随后取出backend.bc.conn,访问对应的后端codis server,并进行相应操作。

七、Codis的不足与个推使用上的改进

Codis的不足
⭕ 欠缺安全考虑,codis fe页面没有登录验证功能;
⭕ 缺乏自带的多租户方案;
⭕ 缺乏集群缩容方案。

个推使用上的改进
⭕ 采用squid代理的方式来简单限制fe页面的访问,后期基于fe进行二次开发来控制登录;
⭕ 小业务通过在key前缀增加业务标识,复用相同集群;大业务使用独立集群,独立机器;
⭕ 采用手动迁移数据、腾空节点、下线节点的方法来缩容。

八、全文总结
Codis作为个推消息推送一项重要的基础服务,性能的好坏至关重要。个推将Redis节点迁移到Codis后,有效地解决了扩充容量和运维管理的难题。未来,个推还将继续关注Codis,与大家共同探讨如何在生产环境中更好地对其进行使用。

继续阅读 »

一、引言
Codis是一个分布式 Redis 解决方案,可以管理数量巨大的Redis节点。个推作为专业的第三方推送服务商,多年来专注于为开发者提供高效稳定的消息推送服务。每天通过个推平台下发的消息数量可达百亿级别。基于个推推送业务对数据量、并发量以及速度的要求非常高,实践发现,单个Redis节点性能容易出现瓶颈,综合考虑各方面因素后,我们选择了Codis来更好地管理和使用Redis。

二、选择Codis的原因
随着公司业务规模的快速增长,我们对数据量的存储需求也越来越大,实践表明,在单个Redis的节点实例下,高并发、海量的存储数据很容易使内存出现暴涨。

此外,每一个Redis的节点,其内存也是受限的,主要有以下两个原因:

一是内存过大,在进行数据同步时,全量同步的方式会导致时间过长,从而增加同步失败的风险;
二是越来越多的redis节点将导致后期巨大的维护成本。

因此,我们对Twemproxy、Codis和Redis Cluster 三种主流redis节点管理的解决方案进行了深入调研。

推特开源的Twemproxy最大的缺点是无法平滑的扩缩容。而Redis Cluster要求客户端必须支持cluster协议,使用Redis Cluster需要升级客户端,这对很多存量业务是很大的成本。此外,Redis Cluster的p2p方式增加了通信成本,且难以获知集群的当前状态,这无疑增加了运维的工作难度。

而豌豆荚开源的Codis不仅可以解决Twemproxy扩缩容的问题,而且兼容了Twemproxy,且在Redis Cluster(Redis官方集群方案)漏洞频出的时候率先成熟稳定下来,所以最后我们使用了Codis这套集群解决方案来管理数量巨大的redis节点。

目前个推在推送业务上综合使用Redis和Codis,小业务线使用Redis,数据量大、节点个数众多的业务线使用Codis。

我们要清晰地理解Codis内部是如何工作的,这样才能更好地保证Codis集群的稳定运行。下面我们将从Codis源码的角度来分析Codis的Dashboard和Proxy是如何工作的。

三、Codis介绍
Codis是一个代理中间件,用GO语言开发而成。Codis 在系统的位置如下图所示 :

Codis是一个分布式Redis解决方案,对于上层应用来说,连接Codis Proxy和连接原生的Redis Server没有明显的区别,有部分命令不支持;

Codis底层会处理请求的转发、不停机的数据迁移等工作,对于前面的客户端来说,Codis是透明的,可以简单地认为客户端(client)连接的是一个内存无限大的Redis服务。

Codis分为四个部分,分别是:
Codis Proxy (codis-proxy)
Codis Dashboard
Codis Redis (codis-server)
ZooKeeper/Etcd

Codis架构

四、Dashboard的内部工作原理

Dashboard介绍
Dashboard是Codis的集群管理工具,所有对集群的操作包括proxy和server的添加、删除、数据迁移等都必须通过dashboard来完成。Dashboard的启动过程是对一些必要的数据结构以及对集群的操作的初始化。

Dashboard启动过程
Dashboard启动过程,主要分为New()和Start()两步。

New()阶段
⭕ 启动时,首先读取配置文件,填充config信息。coordinator的值如果是"zookeeper"或者是"etcd",则创建一个zk或者etcd的客户端。根据config创建一个Topom{}对象。Topom{}十分重要,该对象里面存储了集群中某一时刻所有的节点信息(slot,group,server等),而New()方法会给Topom{}对象赋值。

⭕ 随后启动18080端口,监听、处理对应的api请求。

⭕ 最后启动一个后台线程,每隔一分钟清理pool中无效client。

下图是dashboard在New()时内存中对应的数据结构。


Start()阶段

⭕ Start()阶段,将内存中model.Topom{}写入zk,路径是/codis3/codis-demo/topom。

⭕ 设置topom.online=true。

⭕ 随后通过Topom.store从zk中重新获取最新的slotMapping、group、proxy等数据填充到topom.cache中(topom.cache,这个缓存结构,如果为空就通过store从zk中取出slotMapping、proxy、group等信息并填充cache。不是只有第一次启动的时候cache会为空,如果集群中的元素(server、slot等等)发生变化,都会调用dirtyCache,将cache中的信息置为nil,这样下一次就会通过Topom.store从zk中重新获取最新的数据填充。)

⭕ 最后启动4个goroutine for循环来处理相应的动作 。

创建group过程
创建分组的过程很简单。
⭕ 首先,我们通过Topom.store从zk中重新拉取最新的slotMapping、group、proxy等数据填充到topom.cache中。

⭕ 然后根据内存中的最新数据来做校验:校验group的id是否已存在以及该id是否在1~9999这个范围内。

⭕ 接着在内存中创建group{}对象,调用zkClient创建路径/codis3/codis-demo/group/group-0001。

初始,这个group下面是空的。
{
"id": 1,
"servers": [],
"promoting": {},
"out_of_sync": false
}

添加codis server
⭕接下来,向group中添加codis server。Dashboard首先会去连接后端codis server,判断节点是否正常。

⭕ 接着在codis server上执行slotsinfo命令,如果命令执行失败则会导致cordis server添加进程的终结。

⭕ 之后,通过Topom.store从zk中重新拉取最新的slotMapping、group、proxy等数据填充到topom.cache中,根据内存中的最新数据来做校验,判断当前group是否在做主从切换,如果是,则退出;然后检查group server在zk中是否已经存在。

⭕ 最后,创建一个groupServer{}对象,写入zk。
当codis server添加成功后,就像我们上面说的,Topom{}在Start时,有4个goroutine for循环,其中RefreshRedisStats()就可以将codis server的连接放进topom.stats.redisp.pool中




tips
⭕ Topom{}在Start时,有4个goroutine for循环,其中RefreshRedisStats执行过程中会将codis server的连接放进topom.stats.redisp.pool中;

⭕ RefreshRedisStats()每秒执行一次,里面的逻辑是从topom.cache中获取所有的codis server,然后根据codis server的addr 去topom.stats.redisp.Pool.pool 里面获取client。如果能取到,则执行info命令;如果不能取到,则新建一个client,放进pool中,然后再使用client执行info命令,并将info命令执行的结果放进topom.stats.servers中。

Codis Server主从同步
当一个group添加完成2个节点后,要点击主从同步按钮,将第二个节点变成第一个的slave节点。

⭕ 首先,第一步还是刷新topom.cache。我们通过Topom.store从zk中重新获取最新的slotMapping、group、proxy等数据并把它们填充到topom.cache中。

⭕然后根据最新的数据进行判断:group.Promoting.State != models.ActionNothing,说明当前group的Promoting不为空,即 group里面的两个cordis server在做主从切换,主从同步失败;

group.Servers[index].Action.State == models.ActionPending,说明当前作为salve角色的节点,其状态为pending,主从同步失败;

⭕ 判断通过后,获取所有codis server状态为ActionPending的最大的action.index的值+1,赋值给当前的codis server,然后设置当前作为slave角色的节点的状态为:g.Servers[index].Action.State = models.ActionPending。将这些信息写进zk。

⭕ Topom{}在Start时,有4个goroutine for循环,其中一个用于具体处理主从同步问题。

⭕ 页面上点击主从同步按钮后,内存中对应的数据结构会发生相应的变化:

⭕ 写进zk中的group信息:


tips

Topom{}在Start时,有4个goroutine for循环,其中一个便用于具体来处理主从同步。具体怎么做呢?

首先,通过Topom.store从zk中重新获取最新的slotMapping、group、proxy等数据填充到topom.cache中,待得到最新的cache数据后,获取需要做主从同步的group server,修改group.Servers[index].Action.State == models.ActionSyncing,写入zk中。

其次,dashboard连接到作为salve角色的节点上,开启一个redis事务,执行主从同步命令:

c.Send(“MULTI”) —> 开启事务
c.Send(“config”, “set”, “masterauth”, c.Auth)
c.Send(“slaveof”, host, port)

c.Send(“config”, “rewrite")
c.Send(“client”, “kill”, “type”, “normal")
c.Do(“exec”) —> 事物执行

⭕ 主从同步命令执行完成后,修改group.Servers[index].Action.State == “synced”并将其写入zk中。至此,整个主从同步过程已经全部完成。

codis server在做主从同步的过程中,从开始到完成一共会经历5种状态:

""(ActionNothing) --> 新添加的codis,没有主从关系的时候,状态为空
pending(ActionPending) --> 页面点击主从同步之后写入zk中
syncing(ActionSyncing) --> 后台goroutine for循环处理主从同步时,写入zk的中间状态
synced --> goroutine for循环处理主从同步成功后,写入zk中的状态
synced_failed --> goroutine for循环处理主从同步失败后,写入zk中的状态

slot分配
上文给Codis集群添加了codis server,做了主从同步,接下来我们把1024个slot分配给每个codis server。Codis给使用者提供了多种方式,它可以将指定序号的slot移到某个指定group,也可以将某个group中的多个slot移动到另一个group。不过,最方便的方式是自动rebalance。

通过Topom.store我们首先从zk中重新获取最新的slotMapping、group、proxy等数据填充到topom.cache中,再根据cache中最新的slotMapping和group信息,生成slots分配计划 plans = {0:1, 1:1, … , 342:3, …, 512:2, …, 853:2, …, 1023:3},其中key 为 slot id, value 为 group id。接着,我们按照slots分配计划,更新slotMapping信息:Action.State = ActionPending和Action.TargetId = slot分配到的目标group id,并将更新的信息写回zk中。

Topom{}在Start时,有4个goroutine for循环,其中一个用于处理slot分配。

SlotMapping:



tips
● Topom{}在Start时,有4个goroutine for循环,其中ProcessSlotAction执行过程中就将codis server的连接放进topom.action.redisp.pool中了。

● ProcessSlotAction()每秒执行一次,待里面的一系列处理逻辑执行之后,它会从topom{}.action.redisp.Pool.pool中获取client,随后在redis上执行SLOTSMGRTTAGSLOT命令。如果client能取到,则dashboard会在redis上执行迁移命令;如果不能取到,则新建一个client,放进pool中,然后再使用client执行迁移命令。

SlotMapping中action对应的7种状态:

我们知道Codis是由ZooKeeper来管理的,当Codis的Codis Dashbord改变槽位信息时,其他的Codis Proxy节点会监听到ZooKeeper的槽位变化,并及时同步槽位信息。


总结一下,启动dashboard过程中,需要连接zk、创建Topom这个struct,并通过18080这个端口与集群进行交互,然后将该端口收到的信息进行转发。此外,还需要启动四个goroutine、刷新集群中的redis和proxy的状态,以及处理slot和同步操作。

五、Proxy的内部工作原理

proxy启动过程
proxy启动过程,主要分为New()、Online()、reinitProxy()和接收客户端请求()等4个环节。

New()阶段
⭕ 首先,在内存中新建一个Proxy{}结构体对象,并进行各种赋值。
⭕ 其次,启动11080端口和19000端口。
⭕ 然后启动3个goroutine后台线程,处理对应的操作:
●Proxy启动一个goroutine后台线程,并对11080端口的请求进行处理;
●Proxy启动一个goroutine后台线程,并对19000端口的请求进行处理;
●Proxy启动一个goroutine后台线程,通过ping codis server对后端bc予以维护 。





Online()阶段
⭕ 首先对model.Proxy{}的id进行赋值,Id = ctx.maxProxyId() + 1。若添加第一个proxy时, ctx.maxProxyId() = 0,则第一个proxy的id 为 0 + 1。

⭕ 其次,在zk中创建proxy目录。

⭕之后,对proxy内存数据进行刷新reinitProxy(ctx, p, c)。

⭕ 第四,设置如下代码:
online = true
proxy.online = true
router.online = true
jodis.online = true

⭕ 第五,zk中创建jodis目录。


reinitProxy()
⭕Dashboard从zk[m1] 中重新获取最新的slotMapping、group、proxy等数据填充到topom.cache中。根据cache中的slotMapping和group数据,Proxy可以得到model.Slot{},其里面包含了每个slot对应后端的ip与port。建立每个codis server的连接,然后将连接放进router中。

⭕ Redis请求是由sharedBackendConn中取出的一个BackendConn进行处理的。Proxy.Router中存储了集群中所有sharedBackendConnPool和slot的对应关系,用于将redis的请求转发给相应的slot进行处理,而Router里面的sharedBackendConnPool和slot则是通过reinitProxy()来保持最新的值。

总结一下proxy启动过程中的流程。首先读取配置文件,获取Config对象。其次,根据Config新建Proxy,并填充Proxy的各个属性。这里面比较重要的是填充models.Proxy(详细信息可以在zk中查看),以及与zk连接、注册相关路径。

随后,启动goroutine监听11080端口的codis集群发过来的请求并进行转发,以及监听发到19000端口的redis请求并进行相关处理。紧接着,刷新zk中数据到内存中,根据models.SlotMapping和group在Proxy.router中创建1024个models.Slot。此过程中Router为每个Slot都分配了对应的backendConn,用于将redis请求转发给相应的slot进行处理。

六、Codis内部原理补充说明
Codis中key的分配算法是先把key进行CRC32,得到一个32位的数字,然后再hash%1024后得到一个余数。这个值就是这个key对应着的槽,这槽后面对应着的就是redis的实例。

slot共有七种状态:nothing(用空字符串表示)、pending、preparing、prepared、migrating、finished。

如何保证slots在迁移过程中不影响客户端的业务?
⭕ client端把命令发送到proxy, proxy会算出key对应哪个slot,比如30,然后去proxy的router里拿到Slot{},内含backend.bc和migrate.bc。如果migrate.bc有值,说明slot目前在做迁移操作,系统会取出migrate.bc.conn(后端codis-server连接),并在codis server上强制将这个key迁移到目标group,随后取出backend.bc.conn,访问对应的后端codis server,并进行相应操作。

七、Codis的不足与个推使用上的改进

Codis的不足
⭕ 欠缺安全考虑,codis fe页面没有登录验证功能;
⭕ 缺乏自带的多租户方案;
⭕ 缺乏集群缩容方案。

个推使用上的改进
⭕ 采用squid代理的方式来简单限制fe页面的访问,后期基于fe进行二次开发来控制登录;
⭕ 小业务通过在key前缀增加业务标识,复用相同集群;大业务使用独立集群,独立机器;
⭕ 采用手动迁移数据、腾空节点、下线节点的方法来缩容。

八、全文总结
Codis作为个推消息推送一项重要的基础服务,性能的好坏至关重要。个推将Redis节点迁移到Codis后,有效地解决了扩充容量和运维管理的难题。未来,个推还将继续关注Codis,与大家共同探讨如何在生产环境中更好地对其进行使用。

收起阅读 »

毕业三年,快速升职加薪,带领数十人的技术团队,我是怎么做到的?

java

Mr. Tech经常听到有人吐槽
每天上下班挤地铁
每个月给房东打工
每日Bug改到头秃

但是
忙十年却赶不上同事三年
房价物价年年涨
而你的升职加薪却遥遥无期

为什么你光努力没成绩?
为什么你在职场没有竞争力?
为什么你总被同龄人甩在身后?

其实,这不是因为同事比你聪明,而是因为你没有掌握职场升级打怪的正确窍门。为此,Mr. Tech特地请来个推传说中的优秀“同事”——个推Java主管逍遥,为大家传授一下职场超车、告别打杂的秘诀。

逍遥大学毕业仅仅一年,便协助主管承担了团队管理任务;工作两年,便开始独立负责个推核心技术团队基础推送线;工作三年便正式任命为B2D研发部基础推送线Java主管,现负责管理数十人的核心技术团队。逍遥将职场感悟归结为四点:技术知识体系构建、做好职业规划、思维模式转变、情绪调整及控制。

(以下为逍遥的个人分享)

技术知识体系构建
我经常在面试的过程中会问大家如何构建自己的Java学习体系,来帮助自己更快更好地掌握相关的知识并应用到工作中来。然而,就面试者来看,大多数人对此并没有进行过深入思考,回答起来吞吞吐吐,知识体系不全。为此,我建议大家不妨可以从初学、进阶两方面着手,来全方位提高Java学习能力。

初学
初学者建议从学习的语言基础看起。拿Java举例,设计模式自不必提,Java虚拟机(JVM)原理也是必不可少的,了解JVM底层的运行逻辑可以对如何写出优秀的代码或者分析出现的问题都有很大的助力。还有学习JDK自带的一些基础包源码也是很好的示例,对建立代码编写风格以及使用类库的熟练程度都会有提升。然后是学习常用的框架组件,Java的话就是Spring、Dubbo这些。其它的便是现在互联网公司常用的中间件学习:Redis、Kafka、ZooKeeper、RocketMQ等。学习这些框架组件和中间件,不能停留在表面的使用,内部结构和原理是必须要搞清楚的。建议技术人员深入去看看源码。

此外,经常性的回顾整理也是必不可少的。好记性不如烂笔头。艾宾浩斯遗忘曲线、学习金字塔理论等等前人经验或者科学研究都表明,我们要经常整理总结记录,才能学得更好。这个习惯需要终身保持。

以下Java书籍是我工作过程中发现比较不错的几本,从入门Java到日渐精通,每次看都会有不同的感悟,推荐给大家。

《设计模式》:这本书由国际公认的面向对象软件领域的四位专家合作撰写而成。该书深入分析了面向对象的23个设计模式,并为读者总结了最有价值的面向对象设计经验,提炼了一组设计精美、表达清晰可以在实用环境下提供帮助的软件设计模式。

《深入理解Java虚拟机》:作为整个Java图书领域公认的经典著作和超级畅销书,全书由走近Java、自动内存管理机制、垃圾收集器与内存分配策略、虚拟机性能监控与故障处理工具、调优案例分析与实战五个部分组成,深入分析了JVM的工作原理。

《Java并发编程的艺术》:本书对Java并发编程中最核心的技术即JDK源码、JVM、CPU等进行了深入剖析,为读者总结了Java发编程的框架、工具、原理和方法,对Java从业者在如何应用好这些核心技术方面具有重要的指导意义。

进阶

我理解的进阶应该不再停留于语言编码中了,你需要对整个程序的运行负责:性能怎么提升?高可用怎么实现?服务怎么降级?线上问题怎么处理?这一系列的问题如果你已经开始思考了,那么恭喜你,你已经进入了这一阶段。在进阶过程中,你不仅需要编码能力,你还需要掌握如下几点:

  1. 程序服务的运行状态:对服务程序增加监控、告警、日志来帮助其了解运行状态。不断去观察端详你写的程序真实运行状况,有助于将一些隐患消除于未然,应对突发问题也更容易上手分析并快速找到处理对策。

  2. 计算机基础和操作系统:主要包括CPU调度、中断、内存机制、I/O机制。尽管读过计算机专业的人员一半以上都学过计算机基础和操作系统,但很多人对此认识还不够深刻。针对在实际解决问题的过程中,我们经常碰到的计算机基础和操作系统相关的问题,去总结回顾,会有新的理解。

  3. 网络知识:这方面包括TCP/IP协议与模型、常用的网络问题分析工具(tcpdump、netstat)、常用的代理和负载工具(nginx、lvs等)、DNS和域名服务、CDN服务等。具备这些知识在处理问题和设计方案中会让你有更多的灵感和方向。

  4. 线上硬件环境:这个需要熟悉linux网卡的性能指标和调优参数、cpu核数频率及性能、文件系统的调优参数、内存策略的调整参数,以及机房的网络架构。在性能调优中我们会经常用到这些。

做好职业规划

适合度认知
现在程序员岗位比较热门,不少机械、化工、水利等专业的毕业新生也涌入到了该岗位中。然而,很多人虽然选择了程序员这个行业,但对自己是否适合还是不确定的。

在我看来,代码能力作为未来必备的基础能力之一,先干几年是绝对没问题的。但在干这一行的过程中,你可以慢慢去思考以下几点:我码完代码有满足感吗?我对写过的代码、维护过的项目能做到完全了解吗?我会静下心来去看做过的项目中所用到的技术更深层次的原理吗?每次过需求、做实现是不是都只是安于实现?如果过了2年你的答案都还是否定的,我觉得可以考虑转岗。

短期规划
对于刚入门的程序员,前三年是比较关键的,建议做到几点:
1.扎实技术基础,参考上面提过的技术知识体系构建部分。
2.以增长见识、提升自我为出发点,用开放的心态,主动去接触各个业务各个部门。当然这个以后也是要持续保持的。
3.积极参与各类问题的分析排查,锻炼自己解决问题的能力。

长期定位
长期定位一般分几种:技术专家、技术架构、技术管理。以下我列出三个定位必备的素质,大家看完后可以经常思考下自己的发展方向。

技术专家:具备某一方面或多方面专长,能非常快地解决一般人处理不了的问题,或者代码能力超强,经常编写偏底层的高性能的高可靠的代码;

技术架构:非常熟悉世面上的各种框架和架构模式,能有自己的实际架构经验,善于处理系统出现的各类问题,对于系统如果要达到怎样的目标如何去优化有很强的理解且有很多方案;

技术管理:充分了解团队的方向具备强烈的责任感,熟悉团队成员的优缺点并善于利用,能助人成长、带人成事。

思维模式转变
这点是我目前感触较深的一点,其实这也是任何职场人都必须觉察的。程序员作为一个较特殊的职业,大部分时间面对电脑思考写代码会不自觉过于沉浸自己的代码世界中,觉得写完自己的代码就高枕无忧了。以下几种情况跟大家分享一下,希望大家引以为戒。

  1. 当整体方案涉及多个部门或者多人协作时,经常遇到因为缺少产品交互中隐含的几个接口而导致项目运行不成功的情况,或者因功能实现方界定模糊导致项目运行失败。

  2. 线上运行的程序出了问题没有主观能动性,认为自己了解不多担心解决不了,导致依赖他人的思路和排查。面试中我发现这种问题尤为常见。不少候选者在回答线上运行程序排查问题时都说是上级或者别人解决的,自己对相关问题并不了解。

  3. 只满足于做完本职工作,对团队没有输出足够的个人能力和魅力。

  4. 对整体业务不了解,只关注代码运行逻辑,不知道在整体业务中起到的作用。这种在有较长业务链中出现较多。

  5. 一些可选任务,觉得和自己没什么关系,不去考虑做后会有什么收获。

其实大家要分清楚,自己的目标是什么:只是为了自己当下轻轻松松舒舒服服的写代码,还是说想为整个项目或者团队带来贡献并且自己能够得到成长。如果你有后一种想法,那么你就需要审视自己是不是可以更加主动的发挥自己的能力,去提升自己而不是抱着没我也行,“雨我无瓜”的想法。

之前听过一个说法,国外程序员更关注于做好产品,国内更关注于做好技术,所以很多国外公司可以不用运营不用销售,技术人可以成为布道师,自己推广跟进自己的产品、服务或者开源项目。我也认为“技术布道”是一种非常好的方式,是可成长性很高的思维模式。

情绪调整及控制
我刚毕业时,对一些事也是懵懵懂懂,一开始工作有非常多的新东西要学,语言也不熟,做些小工具也常出错。后来,当我真正开始负责关键模块,逐渐融入团队,做出成绩得到肯定后,我才开始进入状态。回想当初,我的抗压能力帮了我很多。为什么80%以上的技术人员无法快速成长?那都是抗压能力差导致的。只有抗住压力,不被压力所打败,你才能获得成功。

最近参加的一个培训学习,让我意识到这其实是人的情绪风格决定的。大家有兴趣可以看下《大脑的情绪生活》这本书,里面讲了六种情绪风格,其中情绪调整的能力(也叫回弹力)在你沉浸于负面情绪时可以帮到你很多。

总结
互联网时代,拼能力不拼资历。要想不被淘汰,提升自己是关键。逍遥的职场经历有两点Mr.Tech认为尤为关键:坚持构建自己完整的技术体系和主动承担更多责任的工作模式。在技术水平的锤炼上,慢即是快。只有基础扎实了,我们到应用的时候才会信手拈来不费力。

此外,职场态度也很重要。在完成自己本职工作的情况下,程序员可以主动关心其他项目进度,敏感地发现他人的工作中自己可以帮忙,提出改进建议的点。在这解决问题的过程中,你的能力会变强,而能力变强会进一步提高问题的解决速度,进而推动你解决更多更重要的问题。

最后,Mr. Tech希望大家在2019年仅剩的两周里,加快步伐、提升自己的职场技能,更好地迎接2020年的到来:告别迷茫!告别脱发!告别单身!

继续阅读 »

Mr. Tech经常听到有人吐槽
每天上下班挤地铁
每个月给房东打工
每日Bug改到头秃

但是
忙十年却赶不上同事三年
房价物价年年涨
而你的升职加薪却遥遥无期

为什么你光努力没成绩?
为什么你在职场没有竞争力?
为什么你总被同龄人甩在身后?

其实,这不是因为同事比你聪明,而是因为你没有掌握职场升级打怪的正确窍门。为此,Mr. Tech特地请来个推传说中的优秀“同事”——个推Java主管逍遥,为大家传授一下职场超车、告别打杂的秘诀。

逍遥大学毕业仅仅一年,便协助主管承担了团队管理任务;工作两年,便开始独立负责个推核心技术团队基础推送线;工作三年便正式任命为B2D研发部基础推送线Java主管,现负责管理数十人的核心技术团队。逍遥将职场感悟归结为四点:技术知识体系构建、做好职业规划、思维模式转变、情绪调整及控制。

(以下为逍遥的个人分享)

技术知识体系构建
我经常在面试的过程中会问大家如何构建自己的Java学习体系,来帮助自己更快更好地掌握相关的知识并应用到工作中来。然而,就面试者来看,大多数人对此并没有进行过深入思考,回答起来吞吞吐吐,知识体系不全。为此,我建议大家不妨可以从初学、进阶两方面着手,来全方位提高Java学习能力。

初学
初学者建议从学习的语言基础看起。拿Java举例,设计模式自不必提,Java虚拟机(JVM)原理也是必不可少的,了解JVM底层的运行逻辑可以对如何写出优秀的代码或者分析出现的问题都有很大的助力。还有学习JDK自带的一些基础包源码也是很好的示例,对建立代码编写风格以及使用类库的熟练程度都会有提升。然后是学习常用的框架组件,Java的话就是Spring、Dubbo这些。其它的便是现在互联网公司常用的中间件学习:Redis、Kafka、ZooKeeper、RocketMQ等。学习这些框架组件和中间件,不能停留在表面的使用,内部结构和原理是必须要搞清楚的。建议技术人员深入去看看源码。

此外,经常性的回顾整理也是必不可少的。好记性不如烂笔头。艾宾浩斯遗忘曲线、学习金字塔理论等等前人经验或者科学研究都表明,我们要经常整理总结记录,才能学得更好。这个习惯需要终身保持。

以下Java书籍是我工作过程中发现比较不错的几本,从入门Java到日渐精通,每次看都会有不同的感悟,推荐给大家。

《设计模式》:这本书由国际公认的面向对象软件领域的四位专家合作撰写而成。该书深入分析了面向对象的23个设计模式,并为读者总结了最有价值的面向对象设计经验,提炼了一组设计精美、表达清晰可以在实用环境下提供帮助的软件设计模式。

《深入理解Java虚拟机》:作为整个Java图书领域公认的经典著作和超级畅销书,全书由走近Java、自动内存管理机制、垃圾收集器与内存分配策略、虚拟机性能监控与故障处理工具、调优案例分析与实战五个部分组成,深入分析了JVM的工作原理。

《Java并发编程的艺术》:本书对Java并发编程中最核心的技术即JDK源码、JVM、CPU等进行了深入剖析,为读者总结了Java发编程的框架、工具、原理和方法,对Java从业者在如何应用好这些核心技术方面具有重要的指导意义。

进阶

我理解的进阶应该不再停留于语言编码中了,你需要对整个程序的运行负责:性能怎么提升?高可用怎么实现?服务怎么降级?线上问题怎么处理?这一系列的问题如果你已经开始思考了,那么恭喜你,你已经进入了这一阶段。在进阶过程中,你不仅需要编码能力,你还需要掌握如下几点:

  1. 程序服务的运行状态:对服务程序增加监控、告警、日志来帮助其了解运行状态。不断去观察端详你写的程序真实运行状况,有助于将一些隐患消除于未然,应对突发问题也更容易上手分析并快速找到处理对策。

  2. 计算机基础和操作系统:主要包括CPU调度、中断、内存机制、I/O机制。尽管读过计算机专业的人员一半以上都学过计算机基础和操作系统,但很多人对此认识还不够深刻。针对在实际解决问题的过程中,我们经常碰到的计算机基础和操作系统相关的问题,去总结回顾,会有新的理解。

  3. 网络知识:这方面包括TCP/IP协议与模型、常用的网络问题分析工具(tcpdump、netstat)、常用的代理和负载工具(nginx、lvs等)、DNS和域名服务、CDN服务等。具备这些知识在处理问题和设计方案中会让你有更多的灵感和方向。

  4. 线上硬件环境:这个需要熟悉linux网卡的性能指标和调优参数、cpu核数频率及性能、文件系统的调优参数、内存策略的调整参数,以及机房的网络架构。在性能调优中我们会经常用到这些。

做好职业规划

适合度认知
现在程序员岗位比较热门,不少机械、化工、水利等专业的毕业新生也涌入到了该岗位中。然而,很多人虽然选择了程序员这个行业,但对自己是否适合还是不确定的。

在我看来,代码能力作为未来必备的基础能力之一,先干几年是绝对没问题的。但在干这一行的过程中,你可以慢慢去思考以下几点:我码完代码有满足感吗?我对写过的代码、维护过的项目能做到完全了解吗?我会静下心来去看做过的项目中所用到的技术更深层次的原理吗?每次过需求、做实现是不是都只是安于实现?如果过了2年你的答案都还是否定的,我觉得可以考虑转岗。

短期规划
对于刚入门的程序员,前三年是比较关键的,建议做到几点:
1.扎实技术基础,参考上面提过的技术知识体系构建部分。
2.以增长见识、提升自我为出发点,用开放的心态,主动去接触各个业务各个部门。当然这个以后也是要持续保持的。
3.积极参与各类问题的分析排查,锻炼自己解决问题的能力。

长期定位
长期定位一般分几种:技术专家、技术架构、技术管理。以下我列出三个定位必备的素质,大家看完后可以经常思考下自己的发展方向。

技术专家:具备某一方面或多方面专长,能非常快地解决一般人处理不了的问题,或者代码能力超强,经常编写偏底层的高性能的高可靠的代码;

技术架构:非常熟悉世面上的各种框架和架构模式,能有自己的实际架构经验,善于处理系统出现的各类问题,对于系统如果要达到怎样的目标如何去优化有很强的理解且有很多方案;

技术管理:充分了解团队的方向具备强烈的责任感,熟悉团队成员的优缺点并善于利用,能助人成长、带人成事。

思维模式转变
这点是我目前感触较深的一点,其实这也是任何职场人都必须觉察的。程序员作为一个较特殊的职业,大部分时间面对电脑思考写代码会不自觉过于沉浸自己的代码世界中,觉得写完自己的代码就高枕无忧了。以下几种情况跟大家分享一下,希望大家引以为戒。

  1. 当整体方案涉及多个部门或者多人协作时,经常遇到因为缺少产品交互中隐含的几个接口而导致项目运行不成功的情况,或者因功能实现方界定模糊导致项目运行失败。

  2. 线上运行的程序出了问题没有主观能动性,认为自己了解不多担心解决不了,导致依赖他人的思路和排查。面试中我发现这种问题尤为常见。不少候选者在回答线上运行程序排查问题时都说是上级或者别人解决的,自己对相关问题并不了解。

  3. 只满足于做完本职工作,对团队没有输出足够的个人能力和魅力。

  4. 对整体业务不了解,只关注代码运行逻辑,不知道在整体业务中起到的作用。这种在有较长业务链中出现较多。

  5. 一些可选任务,觉得和自己没什么关系,不去考虑做后会有什么收获。

其实大家要分清楚,自己的目标是什么:只是为了自己当下轻轻松松舒舒服服的写代码,还是说想为整个项目或者团队带来贡献并且自己能够得到成长。如果你有后一种想法,那么你就需要审视自己是不是可以更加主动的发挥自己的能力,去提升自己而不是抱着没我也行,“雨我无瓜”的想法。

之前听过一个说法,国外程序员更关注于做好产品,国内更关注于做好技术,所以很多国外公司可以不用运营不用销售,技术人可以成为布道师,自己推广跟进自己的产品、服务或者开源项目。我也认为“技术布道”是一种非常好的方式,是可成长性很高的思维模式。

情绪调整及控制
我刚毕业时,对一些事也是懵懵懂懂,一开始工作有非常多的新东西要学,语言也不熟,做些小工具也常出错。后来,当我真正开始负责关键模块,逐渐融入团队,做出成绩得到肯定后,我才开始进入状态。回想当初,我的抗压能力帮了我很多。为什么80%以上的技术人员无法快速成长?那都是抗压能力差导致的。只有抗住压力,不被压力所打败,你才能获得成功。

最近参加的一个培训学习,让我意识到这其实是人的情绪风格决定的。大家有兴趣可以看下《大脑的情绪生活》这本书,里面讲了六种情绪风格,其中情绪调整的能力(也叫回弹力)在你沉浸于负面情绪时可以帮到你很多。

总结
互联网时代,拼能力不拼资历。要想不被淘汰,提升自己是关键。逍遥的职场经历有两点Mr.Tech认为尤为关键:坚持构建自己完整的技术体系和主动承担更多责任的工作模式。在技术水平的锤炼上,慢即是快。只有基础扎实了,我们到应用的时候才会信手拈来不费力。

此外,职场态度也很重要。在完成自己本职工作的情况下,程序员可以主动关心其他项目进度,敏感地发现他人的工作中自己可以帮忙,提出改进建议的点。在这解决问题的过程中,你的能力会变强,而能力变强会进一步提高问题的解决速度,进而推动你解决更多更重要的问题。

最后,Mr. Tech希望大家在2019年仅剩的两周里,加快步伐、提升自己的职场技能,更好地迎接2020年的到来:告别迷茫!告别脱发!告别单身!

收起阅读 »

通过Flink实现个推海量消息数据的实时统计

背景

消息报表主要用于统计消息任务的下发情况。比如,单条推送消息下发APP用户总量有多少,成功推送到手机的数量有多少,又有多少APP用户点击了弹窗通知并打开APP等。通过消息报表,我们可以很直观地看到消息推送的流转情况、消息下发到达成功率、用户对消息的点击情况等。

个推在提供消息推送服务时,为了更好地了解每天的推送情况,会从不同的维度进行数据统计,生成消息报表。个推每天下发的消息推送数巨大,可以达到数百亿级别,原本我们采用的离线统计系统已不能满足业务需求。随着业务能力的不断提升,我们选择了Flink作为数据处理引擎,以满足对海量消息推送数据的实时统计。

本文将主要阐述选择Flink的原因、Flink的重要特性以及优化后的实时计算方法。

离线计算平台架构

在消息报表系统的初期,我们采用的是离线计算的方式,主要采用spark作为计算引擎,原始数据存放在HDFS中,聚合数据存放在Solr、Hbase和Mysql中:

查询的时候,先根据筛选条件,查询的维度主要有三个:

  1. appId
  2. 下发时间
  3. taskGroupName

根据不同维度可以查询到taskId的列表,然后根据task查询hbase获取相应的结果,获取下发、展示和点击相应的指标数据。在我们考虑将其改造为实时统计时,会存在着一系列的难点:

  1. 原始数据体量巨大,每天数据量达到几百亿规模,需要支持高吞吐量;
  2. 需要支持实时的查询;
  3. 需要对多份数据进行关联;
  4. 需要保证数据的完整性和数据的准确性。

Why Flink

Flink是什么

Flink 是一个针对流数据和批数据的分布式处理引擎。它主要是由 Java 代码实现。目前主要还是依靠开源社区的贡献而发展。

对 Flink 而言,其所要处理的主要场景就是流数据。Flink 的前身是柏林理工大学一个研究性项目, 在 2014 被 Apache 孵化器所接受,然后迅速地成为了 ASF(Apache Software Foundation)的顶级项目之一。

方案对比

为了实现个推消息报表的实时统计,我们之前考虑使用spark streaming作为我们的实时计算引擎,但是我们在考虑了spark streaming、storm和flink的一些差异点后,还是决定使用Flink作为计算引擎:

针对上面的业务痛点,Flink能够满足以下需要:

  1. Flink以管道推送数据的方式,可以让Flink实现高吞吐量。

  2. Flink是真正意义上的流式处理,延时更低,能够满足我们消息报表统计的实时性要求。

  3. Flink可以依靠强大的窗口功能,实现数据的增量聚合;同时,可以在窗口内进行数据的join操作。

  4. 我们的消息报表涉及到金额结算,因此对于不允许存在误差,Flink依赖自身的exact once机制,保证了我们数据不会重复消费和漏消费。

Flink的重要特性

下面我们来具体说说Flink中一些重要的特性,以及实现它的原理:

1)低延时、高吞吐

Flink速度之所以这么快,主要是在于它的流处理模型。

Flink 采用 Dataflow 模型,和 Lambda 模式不同。Dataflow 是纯粹的节点组成的一个图,图中的节点可以执行批计算,也可以是流计算,也可以是机器学习算法。流数据在节点之间流动,被节点上的处理函数实时 apply 处理,节点之间是用 netty 连接起来,两个 netty 之间 keepalive,网络 buffer 是自然反压的关键。

经过逻辑优化和物理优化,Dataflow 的逻辑关系和运行时的物理拓扑相差不大。这是纯粹的流式设计,时延和吞吐理论上是最优的。

简单来说,当一条数据被处理完成后,序列化到缓存中,然后立刻通过网络传输到下一个节点,由下一个节点继续处理。

2)Checkpoint

Flink是通过分布式快照来实现checkpoint,能够支持Exactly-Once语义。

分布式快照是基于Chandy和Lamport在1985年设计的一种算法,用于生成分布式系统当前状态的一致性快照,不会丢失信息且不会记录重复项。

Flink使用的是Chandy Lamport算法的一个变种,定期生成正在运行的流拓扑的状态快照,并将这些快照存储到持久存储中(例如:存储到HDFS或内存中文件系统)。检查点的存储频率是可配置的。

3)backpressure

back pressure出现的原因是为了应对短期数据尖峰。

旧版本Spark Streaming的back pressure通过限制最大消费速度实现,对于基于Receiver 形式,我们可以通过配置spark.streaming. receiver.maxRate参数来限制每个 receiver 每秒最大可以接收的记录的数据。

对于 Direct Approach 的数据接收,我们可以通过配置spark.streaming. kafka.maxRatePerPartition 参数来限制每次作业中每个 Kafka 分区最多读取的记录条数。

但这样是非常不方便的,在实际上线前,还需要对集群进行压测,来决定参数的大小。

Flink运行时的构造部件是operators以及streams。每一个operator消费一个中间/过渡状态的流,对它们进行转换,然后生产一个新的流。

描述这种机制最好的类比是:Flink使用有效的分布式阻塞队列来作为有界的缓冲区。如同Java里通用的阻塞队列跟处理线程进行连接一样,一旦队列达到容量上限,一个相对较慢的接受者将拖慢发送者。

消息报表的实时计算

优化之后,架构升级成如下:

可以看出,我们做了以下几点优化:

  1. Flink替换了之前的spark,进行消息报表的实时计算;
  2. ES替换了之前的Solr。

对于Flink进行实时计算,我们的关注点主要有以下4个方面:

  1. ExactlyOnce保证了数据只会被消费一次
  2. 状态管理的能力
  3. 强大的时间窗口
  4. 流批一体

为了实现我们实时统计报表的需求,主要依靠Flink的增量聚合功能。

首先,我们设置了Event Time作为时间窗口的类型,保证了只会计算当天的数据;同时,我们每隔一分钟增量统计当日的消息报表,因此分配1分钟的时间窗口。

然后我们使用.aggregate (AggregateFunction af, WindowFunction wf) 做增量的聚合操作,它能使用AggregateFunction提前聚合掉数据,减少 state 的存储压力。之后,我们将增量聚合后的数据写入到ES和Hbase中。

流程如下所示:

同时,在查询的时候,我们通过taskID、日期等维度进行查询,先从ES中获取taskID的集合,之后通过taskID查询hbase,得出统计结果。

总结

通过使用Flink,我们实现了对消息推送数据的实时统计,能够实时查看消息下发、展示、点击等数据指标,同时,借助FLink强大的状态管理功能,服务的稳定性也得到了一定的保障。未来,个推也将持续优化消息推送服务,并将Flink引入到其他的业务线中,以满足一些实时性要求高的业务场景需求。

继续阅读 »

背景

消息报表主要用于统计消息任务的下发情况。比如,单条推送消息下发APP用户总量有多少,成功推送到手机的数量有多少,又有多少APP用户点击了弹窗通知并打开APP等。通过消息报表,我们可以很直观地看到消息推送的流转情况、消息下发到达成功率、用户对消息的点击情况等。

个推在提供消息推送服务时,为了更好地了解每天的推送情况,会从不同的维度进行数据统计,生成消息报表。个推每天下发的消息推送数巨大,可以达到数百亿级别,原本我们采用的离线统计系统已不能满足业务需求。随着业务能力的不断提升,我们选择了Flink作为数据处理引擎,以满足对海量消息推送数据的实时统计。

本文将主要阐述选择Flink的原因、Flink的重要特性以及优化后的实时计算方法。

离线计算平台架构

在消息报表系统的初期,我们采用的是离线计算的方式,主要采用spark作为计算引擎,原始数据存放在HDFS中,聚合数据存放在Solr、Hbase和Mysql中:

查询的时候,先根据筛选条件,查询的维度主要有三个:

  1. appId
  2. 下发时间
  3. taskGroupName

根据不同维度可以查询到taskId的列表,然后根据task查询hbase获取相应的结果,获取下发、展示和点击相应的指标数据。在我们考虑将其改造为实时统计时,会存在着一系列的难点:

  1. 原始数据体量巨大,每天数据量达到几百亿规模,需要支持高吞吐量;
  2. 需要支持实时的查询;
  3. 需要对多份数据进行关联;
  4. 需要保证数据的完整性和数据的准确性。

Why Flink

Flink是什么

Flink 是一个针对流数据和批数据的分布式处理引擎。它主要是由 Java 代码实现。目前主要还是依靠开源社区的贡献而发展。

对 Flink 而言,其所要处理的主要场景就是流数据。Flink 的前身是柏林理工大学一个研究性项目, 在 2014 被 Apache 孵化器所接受,然后迅速地成为了 ASF(Apache Software Foundation)的顶级项目之一。

方案对比

为了实现个推消息报表的实时统计,我们之前考虑使用spark streaming作为我们的实时计算引擎,但是我们在考虑了spark streaming、storm和flink的一些差异点后,还是决定使用Flink作为计算引擎:

针对上面的业务痛点,Flink能够满足以下需要:

  1. Flink以管道推送数据的方式,可以让Flink实现高吞吐量。

  2. Flink是真正意义上的流式处理,延时更低,能够满足我们消息报表统计的实时性要求。

  3. Flink可以依靠强大的窗口功能,实现数据的增量聚合;同时,可以在窗口内进行数据的join操作。

  4. 我们的消息报表涉及到金额结算,因此对于不允许存在误差,Flink依赖自身的exact once机制,保证了我们数据不会重复消费和漏消费。

Flink的重要特性

下面我们来具体说说Flink中一些重要的特性,以及实现它的原理:

1)低延时、高吞吐

Flink速度之所以这么快,主要是在于它的流处理模型。

Flink 采用 Dataflow 模型,和 Lambda 模式不同。Dataflow 是纯粹的节点组成的一个图,图中的节点可以执行批计算,也可以是流计算,也可以是机器学习算法。流数据在节点之间流动,被节点上的处理函数实时 apply 处理,节点之间是用 netty 连接起来,两个 netty 之间 keepalive,网络 buffer 是自然反压的关键。

经过逻辑优化和物理优化,Dataflow 的逻辑关系和运行时的物理拓扑相差不大。这是纯粹的流式设计,时延和吞吐理论上是最优的。

简单来说,当一条数据被处理完成后,序列化到缓存中,然后立刻通过网络传输到下一个节点,由下一个节点继续处理。

2)Checkpoint

Flink是通过分布式快照来实现checkpoint,能够支持Exactly-Once语义。

分布式快照是基于Chandy和Lamport在1985年设计的一种算法,用于生成分布式系统当前状态的一致性快照,不会丢失信息且不会记录重复项。

Flink使用的是Chandy Lamport算法的一个变种,定期生成正在运行的流拓扑的状态快照,并将这些快照存储到持久存储中(例如:存储到HDFS或内存中文件系统)。检查点的存储频率是可配置的。

3)backpressure

back pressure出现的原因是为了应对短期数据尖峰。

旧版本Spark Streaming的back pressure通过限制最大消费速度实现,对于基于Receiver 形式,我们可以通过配置spark.streaming. receiver.maxRate参数来限制每个 receiver 每秒最大可以接收的记录的数据。

对于 Direct Approach 的数据接收,我们可以通过配置spark.streaming. kafka.maxRatePerPartition 参数来限制每次作业中每个 Kafka 分区最多读取的记录条数。

但这样是非常不方便的,在实际上线前,还需要对集群进行压测,来决定参数的大小。

Flink运行时的构造部件是operators以及streams。每一个operator消费一个中间/过渡状态的流,对它们进行转换,然后生产一个新的流。

描述这种机制最好的类比是:Flink使用有效的分布式阻塞队列来作为有界的缓冲区。如同Java里通用的阻塞队列跟处理线程进行连接一样,一旦队列达到容量上限,一个相对较慢的接受者将拖慢发送者。

消息报表的实时计算

优化之后,架构升级成如下:

可以看出,我们做了以下几点优化:

  1. Flink替换了之前的spark,进行消息报表的实时计算;
  2. ES替换了之前的Solr。

对于Flink进行实时计算,我们的关注点主要有以下4个方面:

  1. ExactlyOnce保证了数据只会被消费一次
  2. 状态管理的能力
  3. 强大的时间窗口
  4. 流批一体

为了实现我们实时统计报表的需求,主要依靠Flink的增量聚合功能。

首先,我们设置了Event Time作为时间窗口的类型,保证了只会计算当天的数据;同时,我们每隔一分钟增量统计当日的消息报表,因此分配1分钟的时间窗口。

然后我们使用.aggregate (AggregateFunction af, WindowFunction wf) 做增量的聚合操作,它能使用AggregateFunction提前聚合掉数据,减少 state 的存储压力。之后,我们将增量聚合后的数据写入到ES和Hbase中。

流程如下所示:

同时,在查询的时候,我们通过taskID、日期等维度进行查询,先从ES中获取taskID的集合,之后通过taskID查询hbase,得出统计结果。

总结

通过使用Flink,我们实现了对消息推送数据的实时统计,能够实时查看消息下发、展示、点击等数据指标,同时,借助FLink强大的状态管理功能,服务的稳定性也得到了一定的保障。未来,个推也将持续优化消息推送服务,并将Flink引入到其他的业务线中,以满足一些实时性要求高的业务场景需求。

收起阅读 »

面试官:同学,说说 Applink 的使用以及原理

推送

简介

通过 Link这个单词我们可以看出这个是一种链接,使用此链接可以直接跳转到 APP,常用于应用拉活,跨应用启动,推送通知启动等场景。

流程

在AS 上其实已经有详细的使用步骤解析了,这里给大家普及下


快速点击 shift 两次,输入 APPLink 即可找到 AS 提供的集成教程。
在 AS 中已经有详细的使用步骤了,总共分为 4 步

add URL intent filters

创建一个 URL


或者也可以点击 “How it works” 按钮

Add logic to handle the intent

选择通过 applink 启动的入口 activity。
点击完成后,AS 会自动在两个地方进行修改,一个是 AndroidManifest

 <activity android:name=".TestActivity">  
            <intent-filter>  
                <action android:name="android.intent.action.VIEW" />  

                <category android:name="android.intent.category.DEFAULT" />  
                <category android:name="android.intent.category.BROWSABLE" />  

                <data  
                    android:scheme="http"  
                    android:host="geyan.getui.com" />  
            </intent-filter>  
        </activity>

此处多了一个 data,看到这个 data 标签,我们可以大胆的猜测,也许这个 applink 的是一个隐式启动。
另外一个改动点是

    protected void onCreate(@Nullable Bundle savedInstanceState) {  
        super.onCreate(savedInstanceState);  
        setContentView(R.layout.activity_test);  
        // ATTENTION: This was auto-generated to handle app links.  
        Intent appLinkIntent = getIntent();  
        String appLinkAction = appLinkIntent.getAction();  
        Uri appLinkData = appLinkIntent.getData();  
    }

applink 的值即为之前配置的 url 链接,此处是为了接收数据用的,不再多说了。

Associate website

这一步最关键了,需要根据 APP 的证书生成一个 json 文件, APP 安装的时候会去联网进行校验。选择你的线上证书,然后点击生成会得到一个 assetlinks.json 的文件,需要把这个文件放到服务器指定的目录下


基于安全原因,这个文件必须通过 SSL 的 GET 请求获取,JSON 格式如下:

[{  
  "relation": ["delegate_permission/common.handle_all_urls"],  
  "target": {  
    "namespace": "android_app",  
    "package_name": "com.lenny.myapplication",  
    "sha256_cert_fingerprints":  
    ["E7:E8:47:2A:E1:BF:63:F7:A3:F8:D1:A5:E1:A3:4A:47:88:0F:B5:F3:EA:68:3F:5C:D8:BC:0B:BA:3E:C2:D2:61"]  
  }  
}]

sha256_cert_fingerprints 这个参数可以通过 keytool 命令获取,这里不再多说了。
最后把这个文件上传到 你配置的地址/.well-know/statements/json,为了避免今后每个 app 链接请求都访问网络,安卓只会在 app 安装的时候检查这个文件。,如果你能在请求 https://yourdomain.com/.well-known/statements.json 的时候看到这个文件(替换成自己的域名),那么说明服务端的配置是成功的。目前可以通过 http 获得这个文件,但是在M最终版里则只能通过 HTTPS 验证。确保你的 web 站点支持 HTTPS 请求。
若一个host需要配置多个app,assetlinks.json添加多个app的信息。
若一个 app 需要配置多个 host,每个 host 的 .well-known 下都要配置assetlinks.json

有没有想过 url 的后缀是不是一定要写成 /.well-know/statements/json 的?
后续讲原理的时候会涉及到,这里先不细说。

Test device

最后我们本质仅是拿到一个 URL,大多数的情况下,我们会在 url 中拼接一些参数,比如

https://yourdomain.com/products/123?coupon=save90

其中 ./products/123?coupon=save90 是我们之前在第二步填写的 path。
那测试方法多种多样,可以使用通知,也可以使用短信,或者使用 adb 直接模拟,我这边图省事就直接用 adb 模拟了

adb shell am start  
-W -a android.intent.action.VIEW  
-d "https://yourdomain.com/products/123?coupon=save90"  
[包名]

使用这个命令就会自动打开 APP。前提是 yourdomain.com 网站上存在了 web-app 关联文件。

原理

上述这些都简单的啦,依葫芦画瓢就行,下面讲些深层次的东西,不仅要知道会用,还得知道为什么可以这么用,不然和咸鱼有啥区别。

上诉也说了,我们配置的域名是在 activity 的 data 标签的,那是否是可以认为 applink 是一种隐式启动,应用安装的时候根据 data 的内容到这个网页下面去获取 assetlinks.json 进行校验,如果符合条件则把 这个 url 保存在本地,当点击 webview 或者短信里面的 url的时候,系统会自动与本地库中的域名相匹配, 如果匹配失败则会被自动认为是 deeplink 的连接。确认过眼神对吧~~~
也就说在第一次安装 APP 的时候是会去请求 data 标签下面的域名的,并且去请求所获得的域名,那 安装->初次启动 的体验自然会想到是在源码中 PackageManagerService 实现。
一个 APk 的安装过程是极其复杂的,涉及到非常多的底层知识,这里不细说,直接找到校验 APPLink 的入口 PackageManagerService 的 installPackageLI 方法。

PackageMmanagerService.class

private void installPackageLI(InstallArgs args, PackageInstalledInfo res) {  
    final int installFlags = args.installFlags;  
    <!--开始验证applink-->  
    startIntentFilterVerifications(args.user.getIdentifier(), replace, pkg);  
    ...  

    }  

    private void startIntentFilterVerifications(int userId, boolean replacing,  
        PackageParser.Package pkg) {  
    ...  

    mHandler.removeMessages(START_INTENT_FILTER_VERIFICATIONS);  
    final Message msg = mHandler.obtainMessage(START_INTENT_FILTER_VERIFICATIONS);  
    msg.obj = new IFVerificationParams(pkg, replacing, userId, verifierUid);  
    mHandler.sendMessage(msg);  
}

可以看到这边发送了一个 message 为 START_INTENT_FILTER_VERIFICATIONS 的 handler 消息,在 handle 的 run 方法里又会接着调用 verifyIntentFiltersIfNeeded。

private void verifyIntentFiltersIfNeeded(int userId, int verifierUid, boolean replacing,  
        PackageParser.Package pkg) {  
        ...  
        <!--检查是否有Activity设置了AppLink-->  
        final boolean hasDomainURLs = hasDomainURLs(pkg);  
        if (!hasDomainURLs) {  
            if (DEBUG_DOMAIN_VERIFICATION) Slog.d(TAG,  
                    "No domain URLs, so no need to verify any IntentFilter!");  
            return;  
        }  
        <!--是否autoverigy-->  
        boolean needToVerify = false;  
        for (PackageParser.Activity a : pkg.activities) {  
            for (ActivityIntentInfo filter : a.intents) {  
            <!--needsVerification是否设置autoverify -->  
                if (filter.needsVerification() && needsNetworkVerificationLPr(filter)) {  
                    needToVerify = true;  
                    break;  
                }  
            }  
        }  
      <!--如果有搜集需要验证的Activity信息及scheme信息-->  
        if (needToVerify) {  
            final int verificationId = mIntentFilterVerificationToken++;  
            for (PackageParser.Activity a : pkg.activities) {  
                for (ActivityIntentInfo filter : a.intents) {  
                    if (filter.handlesWebUris(true) && needsNetworkVerificationLPr(filter)) {  
                        if (DEBUG_DOMAIN_VERIFICATION) Slog.d(TAG,  
                                "Verification needed for IntentFilter:" + filter.toString());  
                        mIntentFilterVerifier.addOneIntentFilterVerification(  
                                verifierUid, userId, verificationId, filter, packageName);  
                        count++;  
                    }    }   } }  }  
   <!--开始验证-->  
    if (count > 0) {  
        mIntentFilterVerifier.startVerifications(userId);  
    }   
}

对 APPLink 进行了检查,搜集,验证,主要是对 scheme 的校验是否是 http/https,以及是否有 flag 为 Intent.ACTION_DEFAULT与Intent.ACTION_VIEW 的参数,接着是开启验证

PMS#IntentVerifierProxy.class

public void startVerifications(int userId) {  
        ...  
            sendVerificationRequest(userId, verificationId, ivs);  
        }  
        mCurrentIntentFilterVerifications.clear();  
    }  

    private void sendVerificationRequest(int userId, int verificationId,  
            IntentFilterVerificationState ivs) {  

        Intent verificationIntent = new Intent(Intent.ACTION_INTENT_FILTER_NEEDS_VERIFICATION);  
        verificationIntent.putExtra(  
                PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_ID,  
                verificationId);  
        verificationIntent.putExtra(  
                PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_URI_SCHEME,  
                getDefaultScheme());  
        verificationIntent.putExtra(  
                PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_HOSTS,  
                ivs.getHostsString());  
        verificationIntent.putExtra(  
                PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_PACKAGE_NAME,  
                ivs.getPackageName());  
        verificationIntent.setComponent(mIntentFilterVerifierComponent);  
        verificationIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);  

        UserHandle user = new UserHandle(userId);  
        mContext.sendBroadcastAsUser(verificationIntent, user);  
    }

目前 Android 的实现是通过发送一个广播来进行验证的,也就是说,这是个异步的过程,验证是需要耗时的(网络请求),发出去的广播会被 IntentFilterVerificationReceiver 接收到。这个类又会再次 start DirectStatementService,在这个 service 里面又会去调用 DirectStatementRetriever 类。在此类的 retrieveStatementFromUrl 方法中才是真正请求网络的地方

DirectStatementRetriever.class

  @Override  
    public Result retrieveStatements(AbstractAsset source) throws AssociationServiceException {  
        if (source instanceof AndroidAppAsset) {  
            return retrieveFromAndroid((AndroidAppAsset) source);  
        } else if (source instanceof WebAsset) {  
            return retrieveFromWeb((WebAsset) source);  
        } else {  
            throw new AssociationServiceException("Namespace is not supported.");  
        }  
    }  
  private Result retrieveFromWeb(WebAsset asset)  
            throws AssociationServiceException {  
        return retrieveStatementFromUrl(computeAssociationJsonUrl(asset), MAX_INCLUDE_LEVEL, asset);  
    }  
    private String computeAssociationJsonUrl(WebAsset asset) {  
        try {  
            return new URL(asset.getScheme(), asset.getDomain(), asset.getPort(),  
                    WELL_KNOWN_STATEMENT_PATH)  
                    .toExternalForm();  
        } catch (MalformedURLException e) {  
            throw new AssertionError("Invalid domain name in database.");  
        }  
    }  
private Result retrieveStatementFromUrl(String urlString, int maxIncludeLevel,  
                                        AbstractAsset source)  
        throws AssociationServiceException {  
    List<Statement> statements = new ArrayList<Statement>();  
    if (maxIncludeLevel < 0) {  
        return Result.create(statements, DO_NOT_CACHE_RESULT);  
    }  

    WebContent webContent;  
    try {  
        URL url = new URL(urlString);  
        if (!source.followInsecureInclude()  
                && !url.getProtocol().toLowerCase().equals("https")) {  
            return Result.create(statements, DO_NOT_CACHE_RESULT);  
        }  
        <!--通过网络请求获取配置-->  
        webContent = mUrlFetcher.getWebContentFromUrlWithRetry(url,  
                HTTP_CONTENT_SIZE_LIMIT_IN_BYTES, HTTP_CONNECTION_TIMEOUT_MILLIS,  
                HTTP_CONNECTION_BACKOFF_MILLIS, HTTP_CONNECTION_RETRY);  
    } catch (IOException | InterruptedException e) {  
        return Result.create(statements, DO_NOT_CACHE_RESULT);  
    }  

    try {  
        ParsedStatement result = StatementParser  
                .parseStatementList(webContent.getContent(), source);  
        statements.addAll(result.getStatements());  
        <!--如果有一对多的情况,或者说设置了“代理”,则循环获取配置-->  
        for (String delegate : result.getDelegates()) {  
            statements.addAll(  
                    retrieveStatementFromUrl(delegate, maxIncludeLevel - 1, source)  
                            .getStatements());  
        }  
        <!--发送结果-->  
        return Result.create(statements, webContent.getExpireTimeMillis());  
    } catch (JSONException | IOException e) {  
        return Result.create(statements, DO_NOT_CACHE_RESULT);  
    }  
}

到了这里差不多就全部讲完了,本质就是通过 HTTPURLConnection 去发起来一个请求。之前还留了个问题,是不是一定要要 /.well-known/assetlinks.json,到这里是不是可以完全明白了,就是 WELL_KNOWN_STATEMENT_PATH 参数

    private static final String WELL_KNOWN_STATEMENT_PATH = "/.well-known/assetlinks.json";  

缺点

  1. 只能在 Android M 系统上支持
    在配置好了app对App Links的支持之后,只有运行Android M的用户才能正常工作。之前安卓版本的用户无法直接点击链接进入app,而是回到浏览器的web页面。
  2. 要使用App Links开发者必须维护一个与app相关联的网站
    对于小的开发者来说这个有点困难,因为他们没有能力为app维护一个网站,但是它们仍然希望通过web链接获得流量。
  3. 对 ink 域名不太友善
    在测试中发现,国内各大厂商对 .ink 域名不太友善,很多的是被支持了 .com 域名,但是不支持 .ink 域名。
机型 版本 是否识别ink 是否识别com
小米 MI6 Android 8.0 MIUI 9.5
小米 MI5 Android 7.0 MIUI 9.5
魅族 PRO 7 Android 7.0 Flyme 6.1.3.1A
三星 S8 Android 7.0 是,弹框
华为 HonorV10 Android 8.0 EMUI 8.0
oppo R11s Android 7.1.1 ColorOS 3.2
oppo A59s Android 5.1 ColorOS 3.0 是,不能跳转到app 是,不能跳转到app
vivo X6Plus A Android 5.0.2 Funtouch OS_2.5
vivo 767 Android 6.0 Funtouch OS_2.6 是,不能跳转到app 是,不能跳转到app
vivo X9 Android 7.1.1 Funtouch OS_3.1 是,不能跳转到app 是,不能跳转到app

参考

1.官方文档: https://developer.android.com/studio/write/app-link-indexing.html

作者:哈哈将

行业前沿、移动开发、数据建模等干货内容,尽在公众号:个推技术学院

继续阅读 »

简介

通过 Link这个单词我们可以看出这个是一种链接,使用此链接可以直接跳转到 APP,常用于应用拉活,跨应用启动,推送通知启动等场景。

流程

在AS 上其实已经有详细的使用步骤解析了,这里给大家普及下


快速点击 shift 两次,输入 APPLink 即可找到 AS 提供的集成教程。
在 AS 中已经有详细的使用步骤了,总共分为 4 步

add URL intent filters

创建一个 URL


或者也可以点击 “How it works” 按钮

Add logic to handle the intent

选择通过 applink 启动的入口 activity。
点击完成后,AS 会自动在两个地方进行修改,一个是 AndroidManifest

 <activity android:name=".TestActivity">  
            <intent-filter>  
                <action android:name="android.intent.action.VIEW" />  

                <category android:name="android.intent.category.DEFAULT" />  
                <category android:name="android.intent.category.BROWSABLE" />  

                <data  
                    android:scheme="http"  
                    android:host="geyan.getui.com" />  
            </intent-filter>  
        </activity>

此处多了一个 data,看到这个 data 标签,我们可以大胆的猜测,也许这个 applink 的是一个隐式启动。
另外一个改动点是

    protected void onCreate(@Nullable Bundle savedInstanceState) {  
        super.onCreate(savedInstanceState);  
        setContentView(R.layout.activity_test);  
        // ATTENTION: This was auto-generated to handle app links.  
        Intent appLinkIntent = getIntent();  
        String appLinkAction = appLinkIntent.getAction();  
        Uri appLinkData = appLinkIntent.getData();  
    }

applink 的值即为之前配置的 url 链接,此处是为了接收数据用的,不再多说了。

Associate website

这一步最关键了,需要根据 APP 的证书生成一个 json 文件, APP 安装的时候会去联网进行校验。选择你的线上证书,然后点击生成会得到一个 assetlinks.json 的文件,需要把这个文件放到服务器指定的目录下


基于安全原因,这个文件必须通过 SSL 的 GET 请求获取,JSON 格式如下:

[{  
  "relation": ["delegate_permission/common.handle_all_urls"],  
  "target": {  
    "namespace": "android_app",  
    "package_name": "com.lenny.myapplication",  
    "sha256_cert_fingerprints":  
    ["E7:E8:47:2A:E1:BF:63:F7:A3:F8:D1:A5:E1:A3:4A:47:88:0F:B5:F3:EA:68:3F:5C:D8:BC:0B:BA:3E:C2:D2:61"]  
  }  
}]

sha256_cert_fingerprints 这个参数可以通过 keytool 命令获取,这里不再多说了。
最后把这个文件上传到 你配置的地址/.well-know/statements/json,为了避免今后每个 app 链接请求都访问网络,安卓只会在 app 安装的时候检查这个文件。,如果你能在请求 https://yourdomain.com/.well-known/statements.json 的时候看到这个文件(替换成自己的域名),那么说明服务端的配置是成功的。目前可以通过 http 获得这个文件,但是在M最终版里则只能通过 HTTPS 验证。确保你的 web 站点支持 HTTPS 请求。
若一个host需要配置多个app,assetlinks.json添加多个app的信息。
若一个 app 需要配置多个 host,每个 host 的 .well-known 下都要配置assetlinks.json

有没有想过 url 的后缀是不是一定要写成 /.well-know/statements/json 的?
后续讲原理的时候会涉及到,这里先不细说。

Test device

最后我们本质仅是拿到一个 URL,大多数的情况下,我们会在 url 中拼接一些参数,比如

https://yourdomain.com/products/123?coupon=save90

其中 ./products/123?coupon=save90 是我们之前在第二步填写的 path。
那测试方法多种多样,可以使用通知,也可以使用短信,或者使用 adb 直接模拟,我这边图省事就直接用 adb 模拟了

adb shell am start  
-W -a android.intent.action.VIEW  
-d "https://yourdomain.com/products/123?coupon=save90"  
[包名]

使用这个命令就会自动打开 APP。前提是 yourdomain.com 网站上存在了 web-app 关联文件。

原理

上述这些都简单的啦,依葫芦画瓢就行,下面讲些深层次的东西,不仅要知道会用,还得知道为什么可以这么用,不然和咸鱼有啥区别。

上诉也说了,我们配置的域名是在 activity 的 data 标签的,那是否是可以认为 applink 是一种隐式启动,应用安装的时候根据 data 的内容到这个网页下面去获取 assetlinks.json 进行校验,如果符合条件则把 这个 url 保存在本地,当点击 webview 或者短信里面的 url的时候,系统会自动与本地库中的域名相匹配, 如果匹配失败则会被自动认为是 deeplink 的连接。确认过眼神对吧~~~
也就说在第一次安装 APP 的时候是会去请求 data 标签下面的域名的,并且去请求所获得的域名,那 安装->初次启动 的体验自然会想到是在源码中 PackageManagerService 实现。
一个 APk 的安装过程是极其复杂的,涉及到非常多的底层知识,这里不细说,直接找到校验 APPLink 的入口 PackageManagerService 的 installPackageLI 方法。

PackageMmanagerService.class

private void installPackageLI(InstallArgs args, PackageInstalledInfo res) {  
    final int installFlags = args.installFlags;  
    <!--开始验证applink-->  
    startIntentFilterVerifications(args.user.getIdentifier(), replace, pkg);  
    ...  

    }  

    private void startIntentFilterVerifications(int userId, boolean replacing,  
        PackageParser.Package pkg) {  
    ...  

    mHandler.removeMessages(START_INTENT_FILTER_VERIFICATIONS);  
    final Message msg = mHandler.obtainMessage(START_INTENT_FILTER_VERIFICATIONS);  
    msg.obj = new IFVerificationParams(pkg, replacing, userId, verifierUid);  
    mHandler.sendMessage(msg);  
}

可以看到这边发送了一个 message 为 START_INTENT_FILTER_VERIFICATIONS 的 handler 消息,在 handle 的 run 方法里又会接着调用 verifyIntentFiltersIfNeeded。

private void verifyIntentFiltersIfNeeded(int userId, int verifierUid, boolean replacing,  
        PackageParser.Package pkg) {  
        ...  
        <!--检查是否有Activity设置了AppLink-->  
        final boolean hasDomainURLs = hasDomainURLs(pkg);  
        if (!hasDomainURLs) {  
            if (DEBUG_DOMAIN_VERIFICATION) Slog.d(TAG,  
                    "No domain URLs, so no need to verify any IntentFilter!");  
            return;  
        }  
        <!--是否autoverigy-->  
        boolean needToVerify = false;  
        for (PackageParser.Activity a : pkg.activities) {  
            for (ActivityIntentInfo filter : a.intents) {  
            <!--needsVerification是否设置autoverify -->  
                if (filter.needsVerification() && needsNetworkVerificationLPr(filter)) {  
                    needToVerify = true;  
                    break;  
                }  
            }  
        }  
      <!--如果有搜集需要验证的Activity信息及scheme信息-->  
        if (needToVerify) {  
            final int verificationId = mIntentFilterVerificationToken++;  
            for (PackageParser.Activity a : pkg.activities) {  
                for (ActivityIntentInfo filter : a.intents) {  
                    if (filter.handlesWebUris(true) && needsNetworkVerificationLPr(filter)) {  
                        if (DEBUG_DOMAIN_VERIFICATION) Slog.d(TAG,  
                                "Verification needed for IntentFilter:" + filter.toString());  
                        mIntentFilterVerifier.addOneIntentFilterVerification(  
                                verifierUid, userId, verificationId, filter, packageName);  
                        count++;  
                    }    }   } }  }  
   <!--开始验证-->  
    if (count > 0) {  
        mIntentFilterVerifier.startVerifications(userId);  
    }   
}

对 APPLink 进行了检查,搜集,验证,主要是对 scheme 的校验是否是 http/https,以及是否有 flag 为 Intent.ACTION_DEFAULT与Intent.ACTION_VIEW 的参数,接着是开启验证

PMS#IntentVerifierProxy.class

public void startVerifications(int userId) {  
        ...  
            sendVerificationRequest(userId, verificationId, ivs);  
        }  
        mCurrentIntentFilterVerifications.clear();  
    }  

    private void sendVerificationRequest(int userId, int verificationId,  
            IntentFilterVerificationState ivs) {  

        Intent verificationIntent = new Intent(Intent.ACTION_INTENT_FILTER_NEEDS_VERIFICATION);  
        verificationIntent.putExtra(  
                PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_ID,  
                verificationId);  
        verificationIntent.putExtra(  
                PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_URI_SCHEME,  
                getDefaultScheme());  
        verificationIntent.putExtra(  
                PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_HOSTS,  
                ivs.getHostsString());  
        verificationIntent.putExtra(  
                PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_PACKAGE_NAME,  
                ivs.getPackageName());  
        verificationIntent.setComponent(mIntentFilterVerifierComponent);  
        verificationIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);  

        UserHandle user = new UserHandle(userId);  
        mContext.sendBroadcastAsUser(verificationIntent, user);  
    }

目前 Android 的实现是通过发送一个广播来进行验证的,也就是说,这是个异步的过程,验证是需要耗时的(网络请求),发出去的广播会被 IntentFilterVerificationReceiver 接收到。这个类又会再次 start DirectStatementService,在这个 service 里面又会去调用 DirectStatementRetriever 类。在此类的 retrieveStatementFromUrl 方法中才是真正请求网络的地方

DirectStatementRetriever.class

  @Override  
    public Result retrieveStatements(AbstractAsset source) throws AssociationServiceException {  
        if (source instanceof AndroidAppAsset) {  
            return retrieveFromAndroid((AndroidAppAsset) source);  
        } else if (source instanceof WebAsset) {  
            return retrieveFromWeb((WebAsset) source);  
        } else {  
            throw new AssociationServiceException("Namespace is not supported.");  
        }  
    }  
  private Result retrieveFromWeb(WebAsset asset)  
            throws AssociationServiceException {  
        return retrieveStatementFromUrl(computeAssociationJsonUrl(asset), MAX_INCLUDE_LEVEL, asset);  
    }  
    private String computeAssociationJsonUrl(WebAsset asset) {  
        try {  
            return new URL(asset.getScheme(), asset.getDomain(), asset.getPort(),  
                    WELL_KNOWN_STATEMENT_PATH)  
                    .toExternalForm();  
        } catch (MalformedURLException e) {  
            throw new AssertionError("Invalid domain name in database.");  
        }  
    }  
private Result retrieveStatementFromUrl(String urlString, int maxIncludeLevel,  
                                        AbstractAsset source)  
        throws AssociationServiceException {  
    List<Statement> statements = new ArrayList<Statement>();  
    if (maxIncludeLevel < 0) {  
        return Result.create(statements, DO_NOT_CACHE_RESULT);  
    }  

    WebContent webContent;  
    try {  
        URL url = new URL(urlString);  
        if (!source.followInsecureInclude()  
                && !url.getProtocol().toLowerCase().equals("https")) {  
            return Result.create(statements, DO_NOT_CACHE_RESULT);  
        }  
        <!--通过网络请求获取配置-->  
        webContent = mUrlFetcher.getWebContentFromUrlWithRetry(url,  
                HTTP_CONTENT_SIZE_LIMIT_IN_BYTES, HTTP_CONNECTION_TIMEOUT_MILLIS,  
                HTTP_CONNECTION_BACKOFF_MILLIS, HTTP_CONNECTION_RETRY);  
    } catch (IOException | InterruptedException e) {  
        return Result.create(statements, DO_NOT_CACHE_RESULT);  
    }  

    try {  
        ParsedStatement result = StatementParser  
                .parseStatementList(webContent.getContent(), source);  
        statements.addAll(result.getStatements());  
        <!--如果有一对多的情况,或者说设置了“代理”,则循环获取配置-->  
        for (String delegate : result.getDelegates()) {  
            statements.addAll(  
                    retrieveStatementFromUrl(delegate, maxIncludeLevel - 1, source)  
                            .getStatements());  
        }  
        <!--发送结果-->  
        return Result.create(statements, webContent.getExpireTimeMillis());  
    } catch (JSONException | IOException e) {  
        return Result.create(statements, DO_NOT_CACHE_RESULT);  
    }  
}

到了这里差不多就全部讲完了,本质就是通过 HTTPURLConnection 去发起来一个请求。之前还留了个问题,是不是一定要要 /.well-known/assetlinks.json,到这里是不是可以完全明白了,就是 WELL_KNOWN_STATEMENT_PATH 参数

    private static final String WELL_KNOWN_STATEMENT_PATH = "/.well-known/assetlinks.json";  

缺点

  1. 只能在 Android M 系统上支持
    在配置好了app对App Links的支持之后,只有运行Android M的用户才能正常工作。之前安卓版本的用户无法直接点击链接进入app,而是回到浏览器的web页面。
  2. 要使用App Links开发者必须维护一个与app相关联的网站
    对于小的开发者来说这个有点困难,因为他们没有能力为app维护一个网站,但是它们仍然希望通过web链接获得流量。
  3. 对 ink 域名不太友善
    在测试中发现,国内各大厂商对 .ink 域名不太友善,很多的是被支持了 .com 域名,但是不支持 .ink 域名。
机型 版本 是否识别ink 是否识别com
小米 MI6 Android 8.0 MIUI 9.5
小米 MI5 Android 7.0 MIUI 9.5
魅族 PRO 7 Android 7.0 Flyme 6.1.3.1A
三星 S8 Android 7.0 是,弹框
华为 HonorV10 Android 8.0 EMUI 8.0
oppo R11s Android 7.1.1 ColorOS 3.2
oppo A59s Android 5.1 ColorOS 3.0 是,不能跳转到app 是,不能跳转到app
vivo X6Plus A Android 5.0.2 Funtouch OS_2.5
vivo 767 Android 6.0 Funtouch OS_2.6 是,不能跳转到app 是,不能跳转到app
vivo X9 Android 7.1.1 Funtouch OS_3.1 是,不能跳转到app 是,不能跳转到app

参考

1.官方文档: https://developer.android.com/studio/write/app-link-indexing.html

作者:哈哈将

行业前沿、移动开发、数据建模等干货内容,尽在公众号:个推技术学院

收起阅读 »