我的第一个安卓应用终于也有了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;
}

效果图:

蒲公英x1安装docker_xml