我的第一个安卓应用终于也有了APP内安装更新的功能(赶上末班车了吗),记录一些关键点,方方面面的。
托管检测更新和下载服务
由于没有服务器,这两个核心功能可以托管到一些比较好的平台。检测我用的是蒲公英分发(内测阶段),下载用的则是无限蓝云(hhh)。如果蒲公英过审了也可以只用一个,不知道难度大不大……
安装apk
高版本需要fileprovider,其实不用的话直接vmpolicy微调一下也行。
两个关键点都需要在清单文件中处理:1、定义 fileprovider,2:声明权限(否则没有反应)。
manifest:
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application ...>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"
/>
</provider>
</application>
其中 android:resource="@xml/file_paths"
需要提供一个清单文件res/xml/file_paths.xml,可如下:
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-cache-path path="apks/" name="apks"/>
</paths>
这样,需要下载安装包到: 外部储存->临时文件夹( /storage/0/Android/data/包名/cache )中的 apks 文件夹: File target = new File(getExternalCacheDir(), "apks/"+versionName);
,才能被 FileProvider 识别。
Java 调用 :
private void startUpdateInstall(File target) {
try {
Intent intent = new Intent(Intent.ACTION_VIEW);
File file = target;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Uri apkUri = FileProvider.getUriForFile(PDICMainActivity.this, "包名.fileprovider", file);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
} else {
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
Uri uri = Uri.fromFile(file);
intent.setDataAndType(uri, "application/vnd.android.package-archive");
}
startActivity(intent);
} catch (Exception e) {
CMN.debug(e);
}
}
FileProvider还可以包含更多文件夹, 参考 Stack Overflow - ‘Failed to find configured root’:
<files-path/>
-->Context.getFilesDir()
<cache-path/>
-->Context.getCacheDir()
<external-path/>
-->Environment.getExternalStorageDirectory()
<external-files-path/>
-->Context.getExternalFilesDir(String)
<external-cache-path/>
-->Context.getExternalCacheDir()
<external-media-path/>
-->Context.getExternalMediaDirs()
Markdown文本 + 超链接混排,实现优雅界面
每一个版本都可以提炼一些简短的介绍,然后在检测更新的时候一起获取,显示出来。
建议用Markdown格式写更新日志,Markdown 之简洁优雅足以胜任一定的生产力。
有许多开源组件可以展示Markdown,比如io.noties.markwon
或者org.commonmark
,前者体积较大、更加完善,后者更简单,但只是转换为html,需要再配合Html.fromHtml
转换Spannable成才行
而安卓的Textview虽然支持各种图文混排,但有一些bug,比如设置linkedmovement后、再设置文本可选,会导致滚动点击时随机崩溃。
两个办法解决,一是自定义textview,try-catch包绕一些会崩溃的方法如dispatchTouchEvent(不推荐)。二是自定义触摸监听器,在onTouch中自行调用ClickableSpan、UrlSpan、LinkSpan等的的点击方式。
// 自定义触摸监听器,手动调用 ClickableSpan
TextView tv = (TextView) v;
CharSequence text = tv.getText();
if(text instanceof Spannable) {
Spannable span = (Spannable) text;
int x = 触摸位置_X;
int y = 触摸位置_Y;
x -= tv.getTotalPaddingLeft();
y -= tv.getTotalPaddingTop();
x += tv.getScrollX();
y += tv.getScrollY();
Layout layout = tv.getLayout();
if(layout!=null) {
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
ClickableSpan[] link = span.getSpans(off, off, ClickableSpan.class);
if (link.length > 0) {
touching = ……
// 记录 link[0], 然后在ACTION_UP或onClick时,调用点击监听器
}
}
}
@Override
public void onClick(View v) {
ClickableSpan touching = getTouchingSpan(v);
if (touching!=null) {
TextView widget = (TextView) v;
Spannable span = (Spannable) widget.getText();
if (clickInterceptor != null && clickInterceptor.onClick(widget, touching)) {
// intentionally blank
} else {
touching.onClick(v);
}
Selection.setSelection(span,
span.getSpanStart(touching),
span.getSpanEnd(touching));
}
}
轻松实现进度条
这里进度条参考的是百度第一个博客里的:给progressbar设置drawable和自定义progressbar:
purpose_drawable.xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape android:shape="rectangle">
<corners android:radius="4dp"/>
<gradient android:startColor="#EFF3F7"
android:endColor="#EFF3F7"/>
</shape>
</item>
<item android:id="@android:id/progress">
<scale android:scaleWidth="100%">
<shape android:shape="rectangle">
<corners android:radius="4dp"/>
<gradient android:angle="45"
android:startColor="#42D673"
android:endColor="#42D673"/>
</shape>
</scale>
</item>
</layer-list>
细看,里面是两层的layerdrawable,第一层是底色,另一层id/progress则是进度的颜色,不过里面的渐变色似乎没有用到啊。
然后布局代码里给seekbar添加android:progressDrawable:@drawable/purpose_drawable
属性即可,非常快啊,简直不讲武德。
大佬说得好,不仅仅要创造 progress,还要创造 purpose,以后就叫做 purposeBar 吧。
Put Together
下载之时,我直接用进度条替换了对话框底部的其中一个按钮。这种替换操作用着很爽,我甚至提炼了一个方法 ViewUtils.replaceView
,一系列操作原生视图的方法 ……
public static View replaceView(View viewToAdd, View viewToRemove) {
return replaceView(viewToAdd, viewToRemove, true);
}
public static View replaceView(View viewToAdd, View viewToRemove, boolean layoutParams) {
ViewGroup.LayoutParams lp = viewToRemove.getLayoutParams();
ViewGroup vg = (ViewGroup) viewToRemove.getParent();
if(vg!=null) {
int idx = vg.indexOfChild(viewToRemove);
removeView(viewToAdd);
if (layoutParams) {
vg.addView(viewToAdd, idx, lp);
} else {
vg.addView(viewToAdd, idx);
}
removeView(viewToRemove);
}
return viewToAdd;
}
public static boolean removeView(View viewToRemove) {
return removeIfParentBeOrNotBe(viewToRemove, null, false);
}
public static boolean removeIfParentBeOrNotBe(View view, ViewGroup parent, boolean tobe) {
if(view!=null) {
ViewParent svp = view.getParent();
if((parent!=svp) ^ tobe) {
if(svp!=null) {
((ViewGroup)svp).removeView(view);
//CMN.Log("removing from...", svp, view.getParent(), view);
return view.getParent()==null;
}
return true;
}
}
return false;
}
效果图: