一、背景
解决 APP 端的用户来源追踪的需求, 目标能统计用户通过哪些其他渠道实现的下载,当用户点击下载链接,可以将链接上的参数传递到 APP 内部的 h5 上,用于后续的开发流程, 所以就需要用户无论是引导到下载打开app还是直接通过唤起 app 都可以传入参数
二、一些用户来源追踪的方式比较
2-1、ios 官方推荐活动统计
优点:
- 相对来说最真实
缺点:
- 只有官方报表,但有延迟,而且目前看只有下载量统计
- 需要至少五个以上下载才有报表
- 跟业务无法对接,无法满足自定义传参的目的
2-2、android 端
引入官方的Play Install Referrer SDK可在代码中获取渠道
https://play.google.com/store/apps/details?id=com.kalodata.kalodata_android&referrer=${推荐来源参数}
可以在下载地址上通过参数传递
优点:
- 感觉基本满足需求,可以自定义统计下载,注册等能力
- 比较精确的能统计用户的来源
缺点:
- 也是不太方便使用,无法判断当前用户是否是下载还是直接打开app,不太通用,场景可能仅限于下载打开链路的统计
2-3、自己实现
流程图
scheme url
在app端是可以通过scheme url 传参的方式在用户打开app时候接收到参数的 配置类似 app:// 这种协议
实现scheme url 只需要在配置中设置url types
在safari 中输入 myapp://xxxx 即可唤起
接收scheme url 打开app的方法解析链接
AppDelegate.m 中
#pragma mark -- openURL options --
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id>*)options{
return YES;
};
在 iOS 13 及更高版本中,Apple 引入了 Scene Delegate 来管理应用的界面和生命周期,特别是在支持多窗口的设备上,如 iPad。这意味着对于支持多任务和多窗口的应用,处理 URL Scheme 的方式也有所变化
- (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts {
for (UIOpenURLContext *context in URLContexts) {
NSURL *url = context.URL;
// 检查 URL 是否是你应用的 scheme
if ([url.scheme isEqualToString:@"myapp"]) {
// 处理 URL,例如打开特定的视图控制器或传递数据
[self handleCustomSchemeURL:url];
}
}
}
Universal Links
可以通过配置网站链接的方式唤起app, 需要在域名对应服务器后台添加 apple-app-site-association 文件, 并在项目中配置 Associated Domains
测试网页,可以在这个网站测试app的跳转及传参 www.debug-anywhere.com/cn/tools/ur…
如果你的应用不支持多任务(iOS 13 之前的项目),你可以在 AppDelegate 中处理 Universal Links:
AppDelegate.m
- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray * _Nullable))restorationHandler {
if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {
NSURL *url = userActivity.webpageURL;
// 处理 URL
[self handleUniversalLink:url];
}
return YES;
}
- (void)handleUniversalLink:(NSURL *)url {
// 在这里处理你的 Universal Link
if ([url.host isEqualToString:@"yourdomain"]) {
// 执行相关操作,比如打开特定的视图控制器
}
}
ios 13之后
// 处理深度链接 Universal link的回调
- (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity {
if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {
NSURL *url = userActivity.webpageURL;
// 处理 URL
[self handleUniversalLink:url];
}
}
- (void)handleUniversalLink:(NSURL *)url {
// 在这里处理你的 Universal Link
if ([url.host isEqualToString:@"yourdomain"]) {
// 执行相关操作,比如打开特定的视图控制器
}
}
优点:
- 方便实现,灵活传参
缺点:
- 没有后台查看统计数据
- 无法实现用户下载后直接打开时的参数传入,安卓可以实现,ios 官方没有提供这种方式
- 安卓和ios各有不同的实现方式,
2-4、使用第三方的方式实现
1、使用 openInstall
文档 www.openinstall.io/doc/ios_sdk…
2、使用 xInstall
以上两种是搜索时软文最多的,实现的功能和方式原理都比较类似, 核心卖点是用户首次安装唤起时传参的问题, 背后原理是使用ip模糊匹配的方式实现, 当用户在h5端点击链接时,会将当前设备的ip、设备信息、等上传到自己服务器,当用户首次下载好app时,在匹配上传的内容,将参数取到,基本使用费用在一年七八千左右
3、使用友盟 ULink
后面发现友盟 Ulink 也可以实现一样的需求,而且友盟在app端的业务感觉更专业一些, 最重要的是 Ulink 在友盟上是免费的 , 所以后面将介绍如何在ios 和 android 上接入 友盟 SDK
文档: developer.umeng.com/docs/191212…
优点
- 免费
- 可以灵活传参
- 自动给生成通用链接,目的是ios上跨域才能唤起app,如果当前域名是有效的h5页面,点击会打开浏览器页面,无法唤起app
- 不仅有模糊匹配还有剪切板提高匹配成功率
三、接入友盟 SDK
3-1、 后台配置
在友盟后台新建ios和android应用
在这里配置好 schemeUrl app 无法打开时的下载地址, 配置 universal link 开启剪切板能力
3-2、h5 端代码的实现
引入文件
<script src="https://g.alicdn.com/jssdk/u-link/index.min.js"></script>
集成功能
import { LOCALSTORAGE_KEY } from "@/config";
import storage from "@/utils/storage";
import { useEffect, useState } from "react";
const useULink = () => {
const tc = storage.getItem(LOCALSTORAGE_KEY.TRACK_CODE);
const [clipboardText, setclipboardText] = useState("");
useEffect(() => {
if (window.ULink) {
window.ULink({
id: "用户渠道里生成的linkid",
data: {
tc,
},
selector: "#Go_TO_APP",
timeout: 2000,
useOpenInBrowerTips: "default",
lazy: false,
auto: false,
useClipboard: false,
proxyOpenDownload: myDownloadStyle,
onready: function (ctx) {
var u = navigator.userAgent;
var isAndroid = u.indexOf("Android") > -1 || u.indexOf("Adr") > -1;
// 这里对安卓端特殊处理了,因为安卓端在发布google play审核无法通过,改成了复制参数,并在安卓端粘贴参数的方案实现
if (isAndroid) {
if (storage.getItem(LOCALSTORAGE_KEY.TRACK_CODE)) {
return setclipboardText("tc=" + storage.getItem(LOCALSTORAGE_KEY.TRACK_CODE));
}
}
if (ctx.solution?.clipboardToken) {
setclipboardText(ctx.solution.clipboardToken);
}
},
});
}
}, []);
function myDownloadStyle(defaultAction, LinkInstance) {
// 解决安卓未安装,无法下载调转问题
if (LinkInstance.solution.downloadUrl.includes("android")) {
window.location.href = LinkInstance.solution.downloadUrl;
}
return;
// 可以自定义打开失败的弹窗,这里设置不展示弹窗了
const downloadStyle = true;
if (downloadStyle === true) {
const downloadStyleDom = document.createElement("div");
downloadStyleDom.setAttribute("id", "downloadStyle");
downloadStyleDom.innerHTML = `
<div id="download-window" style="
width: 70%;
height: 130px;
padding: 20px;
background-color: #fff;
border: 1px solid #ccc;
position: absolute;
top: 30%;
left: 10%">
<div onclick="window.ulinkCloseDownloadTip()" style="
position: absolute;
top: 4px;
right: 10px;
">X</div>
<p>请点击下方下载按钮跳转至商店进行下载!</p>
<button style="
width: 70%;
height: 40px;
background-color: #3b82fe;
color: #fff;
border-radius: 20px;
border: none;
margin: 0 auto;
display: block;
" onclick="window.ulinkOpenDownload()">立即下载</button>
</div>
`;
window.ulinkCloseDownloadTip = function () {
document.getElementById("download-window").remove();
};
window.ulinkOpenDownload = function () {
window.location.href = LinkInstance.solution.downloadUrl;
};
document.body.appendChild(downloadStyleDom);
} else {
defaultAction();
}
}
return {
clipboardText,
tc,
};
};
export default useULink;
当用户点击带有id 为 Go_TO_APP 的标签时,触发功能, 也可以通过配置改为首次加载即触发
<div
id="Go_TO_APP"
className="btn btn_primary"
onClick={() => {
Taro.setClipboardData({
data: clipboardText || "",
success() {},
});
}}
>
<Trans>Go to the Kalodata APP</Trans>
</div>
3-3、ios 端代码的实现
1、通过 Pods 安装三个库 集成文档
2、初始化SDK, 处理首次加载获取参数的逻辑
#import <UMLink/UMLink.h>
#import <UMCommon/UMConfigure.h>
// 获取首次安装时参数
- (void)installGetParams{
// 这里做了一个延时处理, 正常ios上调用这个sdk需要合规用户必须点击授权同意才可以调用,通过延时2s可以绕过这个限制, 没有延时该sdk初始化后立马请求也会报错
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[UMConfigure initWithAppkey:@"xxxxxxxxxxxxx" channel:@"App Store"];
BOOL hasGetInstallParams = [[NSUserDefaults standardUserDefaults] boolForKey:@"key_Has_Get_InstallParams"];
// 通过参数控制只在安装时调用一次
if (!hasGetInstallParams) {
// 获取安装参数的方法
[MobClickLink getInstallParams:^(NSDictionary *params, NSURL *URL, NSError *error) {
if (error) {
return;
}
if (URL.absoluteString.length > 0||params.count > 0) {
[MobClickLink handleLinkURL:URL delegate: (id<MobClickLinkDelegate>)self];
}
[[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"key_Has_Get_InstallParams"];
}];
}else{
//已经调用过getInstallParam方法,没必要在下次启动时再调用
//但后续仍可在需要时调用,比如demo中的按钮点击
}
});
}
// 实现 MobClickLinkDelegate 的回调方法
- (void)getLinkPath:(NSString *)path params:(NSDictionary *)params{
// 存储自符串tc
NSString *tcValue = [params objectForKey:@"tc"];
if(tcValue){
// 重新加载一次webview 将tc传入 webview 内部的 h5
[self loadWebView: tcValue];
}
}
3、处理深度链接跳转过来的参数
在 SceneDelegate中处理深度链接唤起app的参数
// 处理深度链接 Universal link的回调
- (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivity {
// 处理链接
[MobClickLink handleUniversalLink:userActivity delegate: (id<MobClickLinkDelegate>)self];
}
- (void)getLinkPath:(NSString *)path params:(NSDictionary *)params{
NSString *tcValue = [params objectForKey:@"tc"];
if(tcValue){
// 重新加载一次webview
[[[ViewController alloc] init] loadWebView: tcValue];
}
}
3-4、android 接入友盟 sdk 代码
1、安装友盟库
手动下载最新的库,并存在app/libs下:
AndroidManifest.xml 中增加权限
<!-- 智能超链添加权限 -->
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<!-- 用户复制粘贴权限 -->
<uses-permission android:name="android.permission.READ_CLIPBOARD" />
build.gradle.kts 文件中引入sdk
defaultConfig {
//...
ndk {
abiFilters += listOf("armeabi-v7a","arm64-v8a","x86","x86_64")
}
}
dependencies {
implementation(fileTree("libs") { include("*.aar") })
}
2、初始化 SDK
在启动页的onCreate 初始化预启动配置
SplashActivity.kt
import com.umeng.commonsdk.UMConfigure
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
UMConfigure.preInit(this,"xxxxxxx","google play");
}
MainActivity.kt 中进行初始, google play 审核比较严格这块初始化不能简单通过延时调用,需要弹窗提示用户,用户同意授权后在调用
UMConfigure.init(
this,
"xxxxxx",
"google play",
UMConfigure.DEVICE_TYPE_PHONE,
""
)
3、配置schemeurl
AndroidManifext.xml 访问 kalodata://xxx 时可以唤起打开 app
<activity android:name=".MainActivity"
android:exported="true"
android:launchMode="standard">
<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="kalodata"/>
</intent-filter>
</activity>
4、获取初始化参数
// 获取初始化参数
Handler(Looper.getMainLooper()).postDelayed({
// 尝试访问剪贴板的代码
val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData = clipboardManager.primaryClip
if (clipData != null && clipData.itemCount > 0) {
val item = clipData.getItemAt(0)
val text = item.text?.toString() // 处理获取到的文本数据
MobclickLink.getInstallParams(this@MainActivity,text, umlinkAdapter);
}else{
MobclickLink.getInstallParams(this@MainActivity,true, umlinkAdapter);
}
}, 1000) // 延迟时间可以根据需要调整
MobclickLink.getInstallParams 是获取首次安装参数的方法当剪切板有文本还是没有文本分别处理
umlinkAdapter 是一个类含有不同的处理方法, 我抽离到 utils里
新建 MyUmengLinkAdapter.kt
package com.kalodata.kalodata_android.utils
import android.content.Intent
import android.net.Uri
import android.util.Log
import com.kalodata.kalodata_android.MainActivity
import com.umeng.umlink.UMLinkListener
class MyUmengLinkAdapter(private val mainActivityInstance: MainActivity) : UMLinkListener {
override fun onLink(path: String?, params: HashMap<String, String>?) {
// 处理链接回调
path?.let {
// 处理path
Log.d("onLink path", "" + path)
}
params?.let {
// 处理参数
Log.d("onLink params", "" + params)
}
}
override fun onInstall(installParams: HashMap<String, String>?, uri: Uri?) {
// 处理安装回调
installParams?.let {
// 处理安装参数
Log.d("onLink installParams", "" + installParams)
}
uri?.let {
// 创建一个Intent实例并设置其属性
val intent = Intent(Intent.ACTION_VIEW).apply {
data = uri // 设置URI作为Intent的数据
addCategory(Intent.CATEGORY_BROWSABLE) // 添加BROWSABLE类别,通常用在VIEW Intent中
}
mainActivityInstance.handleIntent(intent)
}
}
override fun onError(error: String?) {
// 处理错误回调
error?.let {
// 处理错误
Log.d("onLink error", "" + error)
}
}
}
使用
val umlinkAdapter = MyUmengLinkAdapter(this@MainActivity)
5、处理深度链接唤起app传参, 安卓里是通过 onNewIntent 方法实现的
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) // 调用父类的方法
handleIntent(intent)
}
6、处理Webview的参数传递,也就是在加载的地址后面拼接参数
// 处理新的intent
fun handleIntent(intent: Intent?) {
val baseUrl = "https://www.baidu.com"
// 检查 Intent 的 action
val uri = intent?.data
if (intent?.action == Intent.ACTION_VIEW) {
// 获取 URI 数据
uri?.let {
val tc = it.getQueryParameter("tc")
if (tc != null) {
Log.d("onLink tc", tc)
}
Log.d("onLink tc", "tc")
val finalUrl = if (tc != null && tc != "") {
"$baseUrl?tc=$tc"
} else {
baseUrl // 如果不存在,使用基本 URL
}
Log.d("onLink finalUrl", finalUrl)
loadWebView(finalUrl) // 调用加载 WebView 的方法
}
}else{
loadWebView(baseUrl)
}
}
private fun loadWebView(finalUrl: String){
webView.loadUrl(finalUrl)
}
3-5、android 去掉友盟sdk 该用复制粘贴方案自己实现
在android上使用友盟超链在提交google play时,审核不通过, 当按照合规内容与隐私条款都严格按照官方提示更改后, 发现审核还是不给通过, 保险起见放弃了安卓上使用友盟sdk获取首次安装参数的方案, 选择通过本机的复制粘贴实现
正常使用友盟sdk也是会复制一段混淆参数的代码过来在经过内部方法解析,我这边直接改成h5那里修改支付复制参数,来这边粘贴替换友盟混淆的代码
修改 MainActivity.kt
Handler(Looper.getMainLooper()).postDelayed({
// 尝试访问剪贴板的代码
val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData = clipboardManager.primaryClip
if (clipData != null && clipData.itemCount > 0) {
val item = clipData.getItemAt(0)
val text = item.text?.toString() // 处理获取到的文本数据
// 检查文本内容是否以 "tc=" 开头
if (text != null && text.startsWith("tc=")) {
handleIntent(intent, text);
}else{
handleIntent(intent,"");
}
}else{
handleIntent(intent,"");
}
}, 1000) // 延迟时间可以根据需要调整
修改h5复制代码 这里对安卓端特殊处理了,因为安卓端在发布google play审核无法通过,改成了复制参数,并在安卓端粘贴参数的方案实现
window.ULink({
id: "用户渠道里生成的linkid",
data: {
tc,
},
selector: "#Go_TO_APP",
timeout: 2000,
useOpenInBrowerTips: "default",
lazy: false,
auto: false,
useClipboard: false,
proxyOpenDownload: myDownloadStyle,
onready: function (ctx) {
var u = navigator.userAgent;
var isAndroid = u.indexOf("Android") > -1 || u.indexOf("Adr") > -1;
// 这里对安卓端特殊处理了,因为安卓端在发布google play审核无法通过,改成了复制参数,并在安卓端粘贴参数的方案实现
if (isAndroid) {
if (storage.getItem(LOCALSTORAGE_KEY.TRACK_CODE)) {
return setclipboardText("tc=" + storage.getItem(LOCALSTORAGE_KEY.TRACK_CODE));
}
}
if (ctx.solution?.clipboardToken) {
setclipboardText(ctx.solution.clipboardToken);
}
},
});
唤起时无需修改代码,仍然是通过intent的处理参数拼接到url上,同上面
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) // 调用父类的方法
handleIntent(intent,"")
}
// 处理新的intent
fun handleIntent(intent: Intent?, tc: String?) {
var baseUrl = "https://www.baidu.com"
// 检查 Intent 的 action
val uri = intent?.data
if (intent?.action == Intent.ACTION_VIEW) {
// 获取 URI 数据
uri?.let {
val tc = it.getQueryParameter("tc")
if (tc != null) {
Log.d("onLink tc", tc)
}
Log.d("onLink tc", "tc")
val finalUrl = if (tc != null && tc != "") {
"$baseUrl?tc=$tc"
} else {
baseUrl // 如果不存在,使用基本 URL
}
Log.d("onLink finalUrl", finalUrl)
loadWebView(finalUrl) // 调用加载 WebView 的方法
}
}else{
if(tc !== ""){
baseUrl = "$baseUrl?$tc"
}
loadWebView(baseUrl)
}
}
四、总结
至此,用户来源相关、APP下载和唤起实现参数的传递就已经都解决了, 主要遇到的问题还是因为 ios 端的下载应用无法传参, 使用第三方sdk 也是主要解决这个问题, 原理就是ip或者利用剪切板模糊匹配,利用友盟超链可以方便、免费的实现该功能,并且在友盟后台还可以分析相关用户来源的信息,比如用户分布、设备情况等等, 功能还是很强大的, 但是需要注意的是,google play对合规问题要求比较高, 所以最好还是不要用国内的sdk