本文档介绍了直投相关内容,您可在阅读文档内容后,了解直投概况。
一、什么是直投
小米手机上非应用商店的分发行为都需介入安装器完成安装激活,需用户多次操作,转化链路较长。而直投恰好能解决这个问题,在媒体侧直接调起小米应用商店官方详情页完成下载安装,将缩短从下载到激活的转化链路,提高转化效率。
直投使用场景:
三方投放、应用内自更新、应用分发等;
二、直投2.0的核心优势
三方渠道分发应用,未经过小米安全检测,用户下载链路长、效率低;直投直接调起商店详情页,缩短下载链路,提升下载效率。
- 三方渠道分发链路:用户点击下载安装-->小米安全检测,允许三方安装-->用户确认下载途径-->用户验证身份安装--安装完成

- 直投2.0分发链路:用户点击下载-->应用商店详情页点击安装-->安装完成

使用直投分发链路实例:
(1)示例1 :戏鲸
- 未使用直投分发链路实例:
示例视频地址:https://kpan.mioffice.cn/webfolder/ext/FddStfzzPFr%24uVm31GQvyw%40%40
密码:Kcy2
- 使用直投分发链路实例
示例视频地址:https://kpan.mioffice.cn/webfolder/ext/gllHybZZ6GP%24uVm31GQvyw%40%40
密码:nl25
(2)示例2:番茄免费小说
- 未使用直投分发链路实例
示例视频地址:https://kpan.mioffice.cn/webfolder/ext/54BHEK%24vgb3%24uVm31GQvyw%40%40
密码:i3bF
- 使用直投分发链路实例
示例视频地址:https://kpan.mioffice.cn/webfolder/ext/dBxEpEr3zfv%24uVm31GQvyw%40%40
密码:c930
(3)示例3:书旗小说
- 未使用直投分发链路实例
示例视频地址:https://kpan.mioffice.cn/webfolder/ext/5znL219N7Vf%24uVm31GQvyw%40%40
密码:I6DZ
- 使用直投分发链路实例:
示例视频地址:https://kpan.mioffice.cn/webfolder/ext/%23B5IYYcqWTL%24uVm31GQvyw%40%40
密码:Y8b3
三、直投流程
为了保障安全性,直投2.0针对开放能力做了校验。直投2.0流程如下:

四、直投2.0能力介绍
1、直投2.0基础版
(1)基础版详情页样式:免费提供全屏详情页样式,满足开发者内容展示需求
全屏详情页具有丰富的应用决策信息,支持富媒体素材。
.png)
(2)安全性升级:增加双重验证,解决恶意劫持、恶意刷量问题,保障分发安全
- 双向加密:应用详情页增加风控验证码、调用签名也做了二次校验。

- 设备绑定:单个生成deeplink链接,A设备使用后,无法在B设备进行二次使用(oaid、imei等标识顺序实别)。
- 频率限制:单个生成deeplink链接,可对单个用户使用的次数限制。
- 时效校验:单个生成 deeplink链接,限制生成后的使用周期。
(3)状态回传能力:小米回传下载链路的更多关键数据
广告投放过程中,市场上提供的投放数据大多是曝光以及激活后链路效果,缺少商店内相关数据,无法分析全投放链路的转化效果。直投详情页支持提供商店内实时转化数据,打通完整的数据链路,助力应用投放效果跟踪,优化投放策略。

(4)虚拟分包能力:实现虚拟归因能力,追踪多渠道投放效果
应用在不同渠道分发时,需制作多个物理包追踪投放效果,维护成本较高。直投详情页提供的虚拟分包能力帮助解决该问题,无需集成SDK,相关分包参数信息由合作方定义且仅有广告主可以获取,提高安全性,节约制作成本。

2、直投2.0定制版(付费开放)
(1)样式升级:多样化的详情页,满足不同投放场景的搭配需求
目前直投详情页提供半屏、弹出式、minicard、H5定制详情页等多种展示样式,有更沉浸的应用下载交互、更丰富的应用决策信息、更灵活的应用介绍样式供选择,支持富媒体素材,可根据自身使用场景以及应用类型选择搭配。


(2)H5个性化定制能力:H5个性化配置,提升内容匹配度
在提供头尾安全信息和下载button条件下,支持配置H5个性化内容,展示内容与广告创意匹配度更高,充分突出应用的特点和优势,提高转化效率。

(3)促激活提醒能力:利用系统激活提醒,实现后端转化再提升
用户在安装完成到激活链路流失较多,直投详情页支持在下载安装完成后,主动对用户进行激活提醒。停留在媒体内的用户会收到弹框提醒,媒体外的用户会收到push推送,提升安装后激活转化。
四、直投2.0定制版
1、固定模板详情页
- 直投详情页可以按媒体定制化需求,提供弹出式、半屏等多种固定模板展示样式,有更沉浸的应用下载交互、更丰富的应用决策信息、更灵活的应用介绍样式供选择,可根据自身使用场景以及应用类型选择搭配。

2、H5个性化详情页
- 除固定模板详情页外,小米应用商店还提供H5个性化详情页,在提供头尾安全信息和下载button条件下,支持配置H5个性化内容,展示内容与广告创意匹配度更高,充分突出应用的特点和优势,提高转化效率。

3、促用户激活
- 用户在安装完成到激活链路流失较多,直投详情页支持在下载安装完成后,主动对用户进行激活提醒。停留在媒体内的用户会收到弹框提醒,媒体外的用户会收到push推送,提升安装后激活转化。

五、能力申请
直投2.0基础版:免费开放。
直投2.0定制版:计费开放,可申请免费内测资格。
如有合作意向,请您按以下方式快速申请:
邮件主题:直投2.0能力申请-【媒体名】
邮件发送至:appstore-zhitou@xiaomi.com
邮件内容包含:
公钥证书。请参考《直投2.0接入技术文档》提前生成公钥证书,并打包成zip格式随邮件发送。
【正文信息】
字段 | 说明 |
申请背景 | 介绍申请直投能力的需求背景或用途 |
使用场景 | 附上:使用直投的实际业务场景or交互流程示意图 |
合作形式 | 广告主/广告媒体/广告平台 |
*使用直投的媒体和包名 | 如果有明确使用直投能力的媒体,需要提供所有媒体的应用名称和包名 (eg. 小米浏览器调起商店下载抖音,小米浏览器为使用媒体,抖音为被分发应用) |
*申请能力 | 1、全屏详情页样式 2、虚拟归因能力 3、下载链路数据回传 按实际需求,选择申请开通的能力 |
申请使用时长 | 填写使用有效期范围,如果为长期使用,请填写“长期” |
邮件答复
自申请之日起,1-3个工作日内完成密钥配置并回复邮件。
注意事项
- 接入完成后,小米应用商店为您分配的业务编码(appClientId)和密钥请注意妥善保存。
- 本文使用的deeplink必须由广告服务端生成然后下发到广告sdk,一定不要在客户端生成。
- 一个deeplink只能单台设备使用。
六、常见问题FAQ
Q1:senderPackageName是什么?有没有什么限制?
A1:senderPackageName是媒体APP包名或宿主APP包名,比如从小米浏览器跳转商店详情页,senderPackageName就是小米浏览器的包名。senderPackageName有安全性限制,如果广告平台/媒体有诉求的话,可以将指定的宿主APP包名加入安全授权白名单。
Q2:测试deeplink时是不是直接在小米手机上试就可以?有没有系统版本或者应用商店版本的限制?
A2:小米手机上的应用商店需要4.4.0及以上版本
Q3:为什么deeplink跳转的不是新详情页,而是以下两种详情页之一?
全屏:

半屏:

A3:以上两种详情页是deeplink鉴权失败后会跳转的详情页,出现的原因可能有:
- 小米手机上的应用商店是4.4.0之前的版本 => 需将商店更新到最新版本,已全量支持各种开放能力
- deeplink某个或某几个参数的值被修改,如detailStyle,但没有重新生成新的签名 => 修改参数值后需生成新的签名和deeplink
- deeplink中的senderPackageName与宿主APP包名不一致 => 需将senderPackageName改成宿主APP包名,然后生成新的签名和deeplink
- deeplink已在其它手机上使用过 => 需生成新的deeplink
- deeplink已过期 => 需将时间戳更新成当前时间,然后生成新的签名和deeplink
- deeplink验签失败 => 请确保deeplink是按照生成deeplink一节中的步骤生成的,是的话请联系小米应用商店的同学,并提供跳转失败的deeplink,以便排查问题
Q4:证书、密钥B、appClientId和密钥A有没有过期时间?
A4:都是一次性生成好的,没有过期时间
Q5:如果宿主APP没有广告SDK,sdkVersion该如何填?
A5:可以填宿主APP的版本号
Q6:referrer如何填写?source, medium, campaign, term, content 中哪些是必须参数?
A6:source, medium, campaign, term, content是归因参数的模板规范,根据合作方需求可以选择其中任何一个或几个,非必填
content://com.xiaomi.marked.provider.DirectMailProvider 接口
Q7:此接口是客户端需要调用的接口吗?
A7:是的,调用provider标准接口查询商店是否支持相应功能
Q8:此接口的调用是否会影响性能?
A8:不会,本地测试的查询时间平均5ms左右
Q9:“客户端接入时,客户端调用请在自身的任务栈中发起下载,不要设置intent的 FLAG_ACTIVITY_NEW_TASK”,这个是不是必须的?
A9: 非必须,客户端调用请在自身的任务栈中发起下载,不要设置intent的 FLAG_ACTIVITY_NEW_TASK,目的是这样不会跳转到商店而中断用户行为,提高下载率。
Q10:接入状态回传了,但没收到状态回传结果,只收到了1005(状态回传鉴权失败)code .
A10:这是由于状态回传鉴权失败了。具体原因可能有:
- 实际调起Deeplink的包名校验失败
- )实际调起Deeplink的包名和Deeplink中的senderPackageName不一致
- )实际调起Deeplink的包名没有加入直投配置中媒体白名单的授权
- Deeplink已经被使用过,一个deeplink只能单台设备使用,否则可能会鉴权失败
- 签名校验失败
- )签名生成方法错误,具体可根据示例代码检查签名生成方法是否有误
- )签名已过期,每个Deeplink是有效期的,根据直投配置中的链接有效期进行判断
- 直投配置没有授权状态回传能力
Q11:【状态回传】如果在应用商店下载一半,然后从微博导流过来继续下,会向微博发送后续下载完成,开始安装这些状态的广播吗?
A11:不会发送的,状态回传和最开始发起下载任务的callerPackage有关。会回传给最后一次发起下载的callerPackageName。发起下载是指发起原始下载(未下载→开始下载)→开始下载)
附录
附录1.deeplink完整参数
参数名称 | 参数类型 | 是否必须 | 是否参与加签 | 字段说明 |
id | string | 是 | 是 | 需要推广的目标应用包名 |
appClientId | string | 是 | 是 | 业务编码,是广告平台/媒体接入直投的唯一标识 |
nonce | long | 是 | 是 | 时间戳 |
sign | string | 是 | 否 | 签名 |
senderPackageName | string | 是 | 是 | 应用跳转来源,可用于后续定向回传下载状态 |
detailStyle | int | 是 | 是 | 详情页交互形式,1:全屏;2:半屏详情页,3:底部弹出式;5:floatcard悬浮卡片,100(内部测试使用) |
topMargin | int | 否 | 有值时参与加签 | 半屏详情页(detailStyle =2)场景页面距离顶部距离(px) |
alpha | int | 否 | 有值时参与加签 | 半屏详情页(detailStyle =2)场景顶部的不透明度,范围0(透明)-255(不透明) |
sdkVersion | string | 否 | 有值时参与加签 | 广告sdk版本号,用于后续排查问题 |
ref | string | 否 | 否 | 商店打点透传参数,用于标识被点击广告投放的位置,如广告投放在banner或者其他组件,由合作方自己定义 |
referrer | string | 否 | 有值时参与加签 | 归因参数,标准的归因数据格式,最多500字符,需要进行url编码,如source%3Dxxx%26medium%3Dcpc%26campaign%3Ddoublescore%26term%3Ddatadriven%26content%3Da |
startSubscribe | boolean | 否 | 有值时参与加签 | 是否自动预约 |
startDownload | boolean | 否 | 有值时参与加签 | 是否自动下载,默认为true |
url | string | 否 | 有值时参与加签 | H5页面地址 |
ext_apkChannel | string | 否 | 否 | 渠道号 |
附录2.deeplink生成示例(java)
package rsa;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.codec.binary.Base64;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Enumeration;
public class Example {
private static final int KEY_SIZE = 2048;
private static final int GROUP_SIZE = KEY_SIZE / 8;
private static final int ENCRYPT_GROUP_SIZE = GROUP_SIZE - 11;
/**
* 私钥加密
*
* @param data
* @return
* @throws Exception
*/
public static byte[] encryptByPrivateKey(byte[] data, PrivateKey privateKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
return codeImpl(data, cipher, ENCRYPT_GROUP_SIZE);
}
/**
* 公钥解密
*
* @param data
* @return
* @throws Exception
*/
public static byte[] decryptByPublicKey(byte[] data, PublicKey publicKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, publicKey);
return codeImpl(data, cipher, GROUP_SIZE);
}
private static byte[] codeImpl(byte[] data, Cipher cipher, int groupSize) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int idx = 0;
while (idx < data.length) {
int remain = data.length - idx;
int segsize = remain > groupSize ? groupSize : remain;
baos.write(cipher.doFinal(data, idx, segsize));
idx += segsize;
}
return baos.toByteArray();
}
/**
* 获取私钥
*
* @param p12Is
* @param password
* @return
* @throws Exception
*/
public static PrivateKey getPrivateKeyByPKCS12(InputStream p12Is, String password) throws Exception {
try {
KeyStore ks = KeyStore.getInstance("PKCS12");
char[] pwd = null;
if ((password == null) || password.trim().equals("")) {
pwd = null;
} else {
pwd = password.toCharArray();
}
ks.load(p12Is, pwd);
Enumeration<String> aliases = ks.aliases();
String keyAlias = null;
if (aliases.hasMoreElements()) {
keyAlias = aliases.nextElement();
}
return (PrivateKey) ks.getKey(keyAlias, pwd);
} finally {
if (p12Is != null) {
try {
p12Is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static PrivateKey getPrivateKeyByPKCS12(String p12File, String password) throws Exception {
return getPrivateKeyByPKCS12(new FileInputStream(p12File), password);
}
/**
* 获取公钥
*
* @param cerfile
* @return
* @throws Exception
*/
public static PublicKey getPublicKeyByX509Cer(String cerfile) throws Exception {
return getPublicKeyByX509Cer(new FileInputStream(cerfile));
}
private static PublicKey getPublicKeyByX509Cer(InputStream x509Is) throws Exception {
try {
CertificateFactory certificatefactory = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) certificatefactory.generateCertificate(x509Is);
System.out.println(JSONObject.toJSONString(cert));
System.out.println(cert.getIssuerDN().getName());
System.out.println(cert.getIssuerX500Principal().getName());
return cert.getPublicKey();
} finally {
if (x509Is != null) {
try {
x509Is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* sha256_HMAC加密
*
* @param message
* 消息
* @return 加密后字符串
*/
public static String sha256Hmac(String message, String serverkey) {
String hash = "";
try {
Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
SecretKeySpec secret_key = new SecretKeySpec(serverkey.getBytes(), "HmacSHA256");
sha256_HMAC.init(secret_key);
byte[] bytes = sha256_HMAC.doFinal(message.getBytes());
hash = byteArrayToHexString(bytes);
} catch (Exception e) {
System.out.println("Error HmacSHA256 ===========" + e.getMessage());
}
return hash;
}
/**
* 将加密后的字节数组转换成字符串
*
* @param b
* 字节数组
* @return 字符串
*/
public static String byteArrayToHexString(byte[] b) {
StringBuilder hs = new StringBuilder();
String stmp;
for (int n = 0; b != null && n < b.length; n++) {
stmp = Integer.toHexString(b[n] & 0XFF);
if (stmp.length() == 1)
hs.append('0');
hs.append(stmp);
}
return hs.toString().toLowerCase();
}
public static void main(String[] args) throws Exception {
// 原始数据(所有按照按照ascii升序排列)
String info = "appClientId=test&detailStyle=1&id=com.miui.fm&nonce=-8162956393368422160:27234970&senderPackageName=com.android.browser";
// 加签
String originalSign = sha256Hmac(info, "${商店分配的密钥A}");
System.out.println("生成的签名:" + originalSign);
// 对签名利用私钥加密保护
// 获取私钥
PrivateKey privateKey = getPrivateKeyByPKCS12("${密钥B对应的p12格式文件路径}", "${密钥B的密码}");
// RSA加密
byte[] result = encryptByPrivateKey(originalSign.getBytes(), privateKey);
// base64编码
String strBase64 = Base64.encodeBase64String(result);
// url编码
String sign = URLEncoder.encode(strBase64, "utf-8");
// 传输 到商店。。。。。。。。。。。。。。。。。。。经过网络传输
// url解码
String enResult = URLDecoder.decode(sign, "utf-8");
// 获取公钥
PublicKey publicKey = getPublicKeyByX509Cer("${密钥B对应的公钥证书的文件路径}");
// 使用公钥解密
byte[] decodedResult = decryptByPublicKey(Base64.decodeBase64(enResult), publicKey);
String decodedSign = new String(decodedResult, "utf-8");
System.out.println("解密后的签名:" + decodedSign);
//判断解密结果与原始签名是否一致
System.out.println(decodedSign.equals(originalSign));
}
}
附录3.常见错误码对照表
编码 | 说明 |
0 | 成功 |
11 | 参数非法 |
12 | 无效的链接 |
13 | 无效的签名 |
14 | 无效的设备 |
15 | 链接使用超频 |
16 | 缺少关键参数 |
17 | 媒体+应用 或 媒体 或 应用 在全局封禁名单中 |
18 | 错误的调起方包名 |
19 | 业务未接入 |
101 | 配置已失效 |
102 | 链接使用超频 |
103 | 缺少必要参数 |
104 | 请求次数超限 |
21 | 样式能力未接入 |
22 | 业务没有合作详情页样式 |
23 | 样式信息不匹配 |
24 | 媒体包名 在媒体黑名单中 或者 不在媒体白名单中 |
25 | 跳转V2详情页 |
26 | 实验样式配置为空 |
27 | 实验样式不匹配 |
28 | 不支持游戏 |
29 | 不支持应用 |
201 | 应用包名 在应用黑名单中 或者 不在应用白名单中 |
31 | 自动下载能力未接入 |
32 | 媒体包名 在媒体黑名单中 或者 不在媒体白名单中 |
33 | 应用包名 在应用黑名单中 或者 不在应用白名单中 |
34 | 自动下载未授权 |
41 | 自动预约能力未接入 |
42 | 媒体包名 在媒体黑名单中 或者 不在媒体白名单中 |
43 | 应用包名 在应用黑名单中 或者 不在应用白名单中 |
44 | 自动预约未授权 |
51 | 促激活能力未接入 |
52 | 媒体包名 在媒体黑名单中 或者 不在媒体白名单中 |
53 | 应用包名 在应用黑名单中 或者 不在应用白名单中 |
54 | 促激活未授权 |
71 | 状态回传能力未接入 |
72 | 媒体包名 在媒体黑名单中 或者 不在媒体白名单中 |
73 | 应用包名 在应用黑名单中 或者 不在应用白名单中 |
74 | 状态回传未授权 |
999 | 系统异常 |