Android MaskedImage

在这次one新版本的UI设计中,其中关于头像的设计中,一部分是圆角头像,一部分是圆形图像,关于这部分的实现有两种方法:第一种就是通过图片的叠加来实现,这种方法相对比较简单,但是如果有多种规格大小的就得提供相应这么多种的图片,第二种就是通过代码实现,这部分实现起来稍微麻烦点,但是如果一旦实现那么代码就可以重用,代码中用起来就比较方便。于是查找了各种资料,今天就来分享下代码实现的方法。

UI的开发最推荐的还是组件式开发,对于多个地方通用的东西抽成一个组件,这样不管是代码复用还是提供给别人使用都是比较方便的做法。在这次UI设计中,有圆形图片,圆角图片,不排除以后还有其他图片,如椭圆图片之类的,所以这次UI组件的开发也采用了继承的方法。

下面就来看下代码实现:

MaskedImage抽象基类

package com.boohee.widgets;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Xfermode;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.widget.ImageView;

public abstract class MaskedImage extends ImageView {
	private static final Xfermode MASK_XFERMODE;
	private Bitmap mask;
	private Paint paint;

	static {
		PorterDuff.Mode localMode = PorterDuff.Mode.DST_IN;
		MASK_XFERMODE = new PorterDuffXfermode(localMode);
	}

	public MaskedImage(Context paramContext) {
		super(paramContext);
	}

	public MaskedImage(Context paramContext, AttributeSet paramAttributeSet) {
		super(paramContext, paramAttributeSet);
	}

	public MaskedImage(Context paramContext, AttributeSet paramAttributeSet, int paramInt) {
		super(paramContext, paramAttributeSet, paramInt);
	}

	public abstract Bitmap createMask();

	protected void onDraw(Canvas paramCanvas) {
		Drawable localDrawable = getDrawable();
		if (localDrawable == null)
			return;
		try {
			if (this.paint == null) {
				Paint localPaint1 = new Paint();
				this.paint = localPaint1;
				this.paint.setFilterBitmap(false);
				Paint localPaint2 = this.paint;
				Xfermode localXfermode1 = MASK_XFERMODE;
				Xfermode localXfermode2 = localPaint2.setXfermode(localXfermode1);
			}
			float f1 = getWidth();
			float f2 = getHeight();
			int i = paramCanvas.saveLayer(0.0F, 0.0F, f1, f2, null, 31);
			int j = getWidth();
			int k = getHeight();
			localDrawable.setBounds(0, 0, j, k);
			localDrawable.draw(paramCanvas);
			if ((this.mask == null) || (this.mask.isRecycled())) {
				Bitmap localBitmap1 = createMask();
				this.mask = localBitmap1;
			}
			Bitmap localBitmap2 = this.mask;
			Paint localPaint3 = this.paint;
			paramCanvas.drawBitmap(localBitmap2, 0.0F, 0.0F, localPaint3);
			paramCanvas.restoreToCount(i);
			return;
		} catch (Exception localException) {
			StringBuilder localStringBuilder = new StringBuilder()
					.append("Attempting to draw with recycled bitmap. View ID = ");
			System.out.println("localStringBuilder=="+localStringBuilder);
		}
	}
}

CircularImage(圆形图片)实现类

public class CircularImage extends MaskedImage {
	public CircularImage(Context paramContext) {
		super(paramContext);
	}

	public CircularImage(Context paramContext, AttributeSet paramAttributeSet) {
		super(paramContext, paramAttributeSet);
	}

	public CircularImage(Context paramContext, AttributeSet paramAttributeSet, int paramInt) {
		super(paramContext, paramAttributeSet, paramInt);
	}

	public Bitmap createMask() {
		int i = getWidth();
		int j = getHeight();
		Bitmap.Config localConfig = Bitmap.Config.ARGB_8888;
		Bitmap localBitmap = Bitmap.createBitmap(i, j, localConfig);
		Canvas localCanvas = new Canvas(localBitmap);
		Paint localPaint = new Paint(1);
		localPaint.setColor(-16777216);
		float f1 = getWidth();
		float f2 = getHeight();
		RectF localRectF = new RectF(0.0F, 0.0F, f1, f2);
		localCanvas.drawOval(localRectF, localPaint);
		return localBitmap;
	}
}

RoundedCornersImage(圆角图片)实现类

public class RoundedCornersImage extends MaskedImage {
	private static final int DEFAULT_CORNER_RADIUS = 8;
	private int cornerRadius = DEFAULT_CORNER_RADIUS;

	public RoundedCornersImage(Context paramContext) {
		super(paramContext);
	}

	public RoundedCornersImage(Context paramContext, AttributeSet paramAttributeSet) {
		super(paramContext, paramAttributeSet);
		int[] arrayOfInt = R.styleable.RoundedCornersImage;
		TypedArray a = paramContext.obtainStyledAttributes(paramAttributeSet, arrayOfInt);
		int i = a.getDimensionPixelSize(0, DEFAULT_CORNER_RADIUS);
		this.cornerRadius = i;
		a.recycle();
	}

	public Bitmap createMask() {
		int i = getWidth();
		int j = getHeight();
		Bitmap.Config localConfig = Bitmap.Config.ARGB_8888;
		Bitmap localBitmap = Bitmap.createBitmap(i, j, localConfig);
		Canvas localCanvas = new Canvas(localBitmap);
		Paint localPaint = new Paint(1);
		localPaint.setColor(-16777216);
		float f1 = getWidth();
		float f2 = getHeight();
		RectF localRectF = new RectF(0.0F, 0.0F, f1, f2);
		float f3 = this.cornerRadius;
		float f4 = this.cornerRadius;
		localCanvas.drawRoundRect(localRectF, f3, f4, localPaint);
		return localBitmap;
	}
}

如果以后新增了椭圆图片,那么只须新建一个椭圆图片累继承自MaskedImage,然后重写createMask()方法即可。

至此,代码实现已完成,只需要在xml中像使用android自带组件的方式使用我们的自定义组件即可,如:

<com.boohee.widgets.RoundedCornersImage
    android:id="@+id/rounded_image"
    android:layout_width="60dp"
    android:layout_height="60dp" />

Android SqliteAssetHelper

前面有一篇blog提到在Android开发中我们一般有两种方式使用sqlite,第一种是在application中手动创建,然后程序中管理数据库的升级;第二种是预先放置一份sqlite数据库,程序中使用的时候仅是查询功能,并不会涉及到更改、删除操作。这种情况下多是起到提供一个基础资源库的作用,如预先放置的一些提醒励志语句、以及预先放置的一些食物数据等。今天就来总结下如何管理assets文件夹下的sqlite数据库。

使用场景与策略

数据库管理一般都会伴随着升级,试想放在assets文件夹下的数据库升级是该怎么处理呢?

首先放在assets文件夹里的sqlite文件一定是我们事先经过处理好的数据库,包括里面的数据也是我们人为的生成的,如我们的app其实就是根据后端mysql转换成的sqlite,但是后端的数据是会不断完善以及不断变化的,所以伴随着我们的app端的sqlite也会是不断完善的,我想这种情况下大多数的策略是后端重新生成一份最新的sqlite文件,然后等到app发布的时候直接拷贝并覆盖原来旧的数据库。基于这种场景参考了github上一些资料,定义了一个SqliteAssetHelper来管理数据库的升级。下面看代码:

public class SQLiteAssetHelper extends SQLiteOpenHelper {
	static final String TAG = SQLiteAssetHelper.class.getSimpleName();
	private static final String ASSET_DB_PATH = "databases";
	
	private final Context mContext;
	private final String mName;
	private final CursorFactory mFactory;
	private final int mNewVersion;

	private SQLiteDatabase mDatabase = null;
	private boolean mIsInitializing = false;

	private String mDatabasePath;
	private String mArchivePath;

	public SQLiteAssetHelper(Context context, String name, CursorFactory factory, int version) {
		super(context, name, factory, version);
		
		if (version < 1) throw new IllegalArgumentException("Version must be >= 1, was " + version);
		if (name == null) throw new IllegalArgumentException("Databse name cannot be null");
		
		mContext = context;
		mName = name;
		mFactory = factory;
		mNewVersion = version;
		
		mArchivePath = ASSET_DB_PATH + "/" + name + ".zip";
		mDatabasePath = context.getApplicationInfo().dataDir + "/databases";
	}

	@Override
	public void onCreate(SQLiteDatabase db) {
		// do nothing - createOrOpenDatabase() is called in 
		// getWritableDatabase() to handle database creation.
	}

	@Override
	public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
		
	}
	
	@Override
	public synchronized SQLiteDatabase getWritableDatabase() {
		if (mDatabase != null && mDatabase.isOpen() && !mDatabase.isReadOnly()) {
			return mDatabase;  // The database is already open for business
		}

		if (mIsInitializing) {
			throw new IllegalStateException("getWritableDatabase called recursively");
		}

		// If we have a read-only database open, someone could be using it
		// (though they shouldn't), which would cause a lock to be held on
		// the file, and our attempts to open the database read-write would
		// fail waiting for the file lock.  To prevent that, we acquire the
		// lock on the read-only database, which shuts out other users.

		boolean success = false;
		SQLiteDatabase db = null;
		//if (mDatabase != null) mDatabase.lock();
		try {
			mIsInitializing = true;
			db = createOrOpenDatabase(false);

			int version = db.getVersion();
			Log.e(TAG, "old version:" + version);
			Log.e(TAG, "new version:" + mNewVersion);
			
			// do force upgrade
			if (version != 0 && version < mNewVersion) {
				db = createOrOpenDatabase(true);
				version = db.getVersion();
			}
			
			onOpen(db);
			success = true;
			return db;
		} finally {
			mIsInitializing = false;
			if (success) {
				if (mDatabase != null) {
					try { mDatabase.close(); } catch (Exception e) { }
					//mDatabase.unlock();
				}
				mDatabase = db;
			} else {
				//if (mDatabase != null) mDatabase.unlock();
				if (db != null) db.close();
			}
		}

	}
	
	@Override
	public synchronized SQLiteDatabase getReadableDatabase() {
		if (mDatabase != null && mDatabase.isOpen()) {
			return mDatabase;  // The database is already open for business
		}

		if (mIsInitializing) {
			throw new IllegalStateException("getReadableDatabase called recursively");
		}

		try {
			return getWritableDatabase();
		} catch (SQLiteException e) {
			if (mName == null) throw e;  // Can't open a temp database read-only!
			Log.e(TAG, "Couldn't open " + mName + " for writing (will try read-only):", e);
		}

		SQLiteDatabase db = null;
		try {
			mIsInitializing = true;
			String path = mContext.getDatabasePath(mName).getPath();
			db = SQLiteDatabase.openDatabase(path, mFactory, SQLiteDatabase.OPEN_READONLY);
			if (db.getVersion() != mNewVersion) {
				throw new SQLiteException("Can't upgrade read-only database from version " +
						db.getVersion() + " to " + mNewVersion + ": " + path);
			}

			onOpen(db);
			Log.w(TAG, "Opened " + mName + " in read-only mode");
			mDatabase = db;
			return mDatabase;
		} finally {
			mIsInitializing = false;
			if (db != null && db != mDatabase) db.close();
		}
	}
	
	private SQLiteDatabase createOrOpenDatabase(boolean force) throws SQLiteAssetException {		
		SQLiteDatabase db = returnDatabase();
		if (db != null) {
			// database already exists
			if (force) {
				Log.w(TAG, "forcing database upgrade!");
				copyDatabaseFromAssets();
				db = returnDatabase();
				db.setVersion(mNewVersion);
			}
			return db;
		} else {
			// database does not exist, copy it from assets and return it
			copyDatabaseFromAssets();
			db = returnDatabase();
			db.setVersion(mNewVersion);
			return db;
		}
	}
	
	private SQLiteDatabase returnDatabase(){
		try {
			SQLiteDatabase db = SQLiteDatabase.openDatabase(mDatabasePath + "/" + mName, mFactory, SQLiteDatabase.OPEN_READWRITE);
			Log.i(TAG, "successfully opened database " + mName);
			return db;
		} catch (SQLiteException e) {
			Log.w(TAG, "could not open database " + mName + " - " + e.getMessage());
			return null;
		}
	}

	private void copyDatabaseFromAssets() throws SQLiteAssetException {
		Log.e(TAG, "copying database from assets...");

		try {
			InputStream zipFileStream = mContext.getAssets().open(mArchivePath);
			File f = new File(mDatabasePath + "/");
			if (!f.exists()) { f.mkdir(); }

			ZipInputStream zis = getFileFromZip(zipFileStream);
			if (zis == null) {
				throw new SQLiteAssetException("Archive is missing a SQLite database file"); 
			}
			writeExtractedFileToDisk(zis, new FileOutputStream(mDatabasePath + "/" + mName));

			Log.e(TAG, "database copy complete");

		} catch (FileNotFoundException fe) {
			SQLiteAssetException se = new SQLiteAssetException("Missing " + mArchivePath + " file in assets or target folder not writable");
			se.setStackTrace(fe.getStackTrace());
			throw se;
		} catch (IOException e) {
			SQLiteAssetException se = new SQLiteAssetException("Unable to extract " + mArchivePath + " to data directory");
			se.setStackTrace(e.getStackTrace());
			throw se;
		}
	}
	
	private void writeExtractedFileToDisk(ZipInputStream zin, OutputStream outs) throws IOException {
		byte[] buffer = new byte[1024];
		int length;
		while ((length = zin.read(buffer))>0){
			outs.write(buffer, 0, length);
		}
		outs.flush();
		outs.close();
		zin.close();
	}

	private ZipInputStream getFileFromZip(InputStream zipFileStream) throws FileNotFoundException, IOException {
		ZipInputStream zis = new ZipInputStream(zipFileStream);
		ZipEntry ze = null;
		while ((ze = zis.getNextEntry()) != null) {
			Log.e(TAG, "extracting file: '" + ze.getName() + "'...");
			return zis;
		}
		return null;
	}
}

使用方法

使用时只需把实现准备好的sqlite文件压缩成zip包放在assets文件夹下的databases目录,然后定义一个Helper继承自SqliteAssetHelper,如下代码:

public class DBHelper extends SQLiteAssetHelper {
	
	private static final String DATABASE_NAME = "bhdb.sqlite";
	private static final int DATABASE_VERSION = 1;

	public DBHelper(Context context) {
		super(context, DATABASE_NAME, null, DATABASE_VERSION);
	}
}

升级的时候只需要改DATABASE_VERSION的值就好了。

优点与缺点

优点:管理assets文件夹下的数据库简单方便,把sqlite文件以zip包的形式放在程序中,减少包大小。 缺点:每次更新只能覆盖原来的数据,及时是少量数据更新也是这种方式。如果少量数据更新的话打算以执行sql的方式来更新数据那可以参考下面这个项目, android-sqlite-asset-helper


Android BuildConfig.DEBUG的妙用

在Android开发中,我们使用android.util.Log来打印日志,方便我们的开发调试。但是这些代码不想在发布后执行,我们并不想在软件发布后调试日志被其他开发者看到,现在我的方法是设置一个全局变量,标记软件为Debug模式还是Release模式。来看下代码:

public class Log {
    private static final boolean DEBUG = true;

    public static void i(String tag, String msg) {
        if (DEBUG)
            android.util.Log.i(tag, msg);
    }

    public static void e(String tag, String msg) {
        if (DEBUG)
            android.util.Log.e(tag, msg);
    }

    public static void d(String tag, String msg) {
        if (DEBUG)
            android.util.Log.d(tag, msg);
    }

    public static void v(String tag, String msg) {
        if (DEBUG)
            android.util.Log.v(tag, msg);
    }

    public static void w(String tag, String msg) {
        if (DEBUG)
            android.util.Log.w(tag, msg);
    }
}

这样打包发布之前只要改下DEBUG=false就行了,但是每次在发布之前都要手动去改这个变量,不是很方便,而且不排除开发者忘记改的情况。那么有没有更好更方便的做法呢?

ADT(r17)发布以后,Google为我们提供了一种新的调试机制,即BuildConfig.DEBUG。

ADT 17.0.0的New build features第二条如下描述:

Added a feature that allows you to run some code only in debug mode. Builds now generate a class called BuildConfig containing a DEBUGconstant that is automatically set according to your build type. You can check the (BuildConfig.DEBUG) constant in your code to run debug-only functions.

即:新增了一个特性,允许开发者只在Debug模式下运行部分代码。Builds会生成一个叫做BuildConfig的类,该类包含一个名为DEBUG的常量,其常量值会依据开发者的Build类型自动设定。如此,便可以利用BuildConfig.DEBUG来实现只在Debug模式下运行的代码。

如果你的ADT已经更新到17及以上版本,可以尝试在Eclipse中新建一个Android工程,你会发现和R.java同级目录下多了一个叫做BuildConfig.java的类,其内容如下:

/** Automatically generated file. DO NOT MODIFY */
package com.boohee.one;

public final class BuildConfig {
    public final static boolean DEBUG = true;
}

这样只需要改动一行代码就ok了,

private static final boolean DEBUG = BuildConifg.DEBUG;

在上面提到,DEBUG会根据Build类型自动设定。那么Build类型又从哪里区分呢?很简单,点开Eclipse的Project菜单便可见分晓,如下图:

可见,Build类型分为Build Project和Build Automatically,即手动和自动。

需要注意的是,如果直接通过Eclipse运行Project,则不论Build是手动还是自动,DEBUG均不会被设定为false。这是为什么呢?这就牵涉到Android 签名的问题,这里只简单提一下,不赘述:直接通过Eclipse运行Project,Eclipse会在工程Build完毕后在bin目录下生成一个apk,这个apk的签名是调试模式(debug mode),和发布模式(release mode)签名生成的apk略有不同。如此,该问题产生原因便浮出水面。

此时肯定会有人说,直接使用Android Tools–>Export Signed Application Package导出的release mode apk,其DEBUG就是false。这是不对的。在生成Release版时,需要区分Build的类型。如果选择的是自动Build,那么DEBUG仍然会被设定为true。所以在生成Release版时,请按照下面这个步骤进行打包,BuildConfig.DEBUG会被修改为false:

  1. Project -> Build Automatically,即取消Build Automatically

  2. Project -> Clean

  3. Project -> Build

  4. Android Tools -> Export Android application


Android Copy Sqlite From Assets

背景

我们都知道,在Android开发中,一般都使用sqlite来进行数据的存储与访问,而使用sqlite也可分为两种方式,一是在app中手动创建database,这种情况下大多使用SQLiteOpenHelper来进行数据库的创建与升级管理;另一种则是我们事先处理一份只读的数据库放在assets文件夹里,使用的时候则必须先把sqlite文件从assets文件里里拷贝到程序中才可使用,这种情况下的升级则是通过判断版本号来直接覆盖拷贝。

问题

在one的项目中以上两种做法都用到了,本地放了一份只读的部分食物数据,大概1.1M左右,用户的记录数据如:饮食、运动、体重记录存储在自己创建的另一个数据库中。这看似没什么问题,实际测试中也没发现有什么异常。项目上线后后台监测会有一些异常出现,大概意思是food_groups找不到这个表之类的,由于异常次数比较少,加上本地测试也没重现,所以也没在意。但是这个异常每一个版本都会出现,加上偶尔有个别用户反馈食物查询不了,甚是纳闷。

解决

通过询问几位用户以及后台错误的版本分布来看,大多集中在2.2。这个时候便定位到了问题,2.2版本一下的已经非常少了,实际测试时公司也没有那么低版本的手机了,所以一直没有重现,于是在模拟器上用2.1版本进行测试,果然查询模块无法查看。通过监测日志发现,在2.1版本copy数据库时失败,但是在2.3以上版本却一直正常。经过google查看了很多资料,加上自己实际的测试才发现在2.3以下版本的系统中放在assets文件夹里的数据库文件大小不能超过1M,于是经过重新整理把文件缩小到900多k,再在2.1版本的模拟器上运行,一些ok了。

总结

安卓平台上的版本兼容问题确实是令人比较头痛的事,但是4.0之后这些兼容问题不再那么明显了,就目前来看我们的app还是要兼容大多数的系统版本的,后台的异常还是有很大作用的,在测试机器不充分的条件下,这种方式还是非常依赖的。相信等4.0以后的版本普及之后,兼容问题便不用花费太多精力了。


Android批量插入数据性能优化

最近做数据同步时遇到一个问题,在下载数据时需要批量的向sqlite插入数据,虽然数据不算多,但是实际测试中每插入一条数据需要将近50ms的时间,这意味着100条数据就需要花费5s左右的时间,对于用户来说,体验太差了,必须要优化。

在google了之后,发现了sqlite的事务处理问题,在sqlite插入数据的时候默认一条语句就是一个事务,有多少条数据就有多少次磁盘操作。明白了这个,解决方案就有了,在批量插入数据的时候,只开启一个事务,这样只会进行一次磁盘操作,代码如下:

db.beginTransaction();
try {
    for (...) {
        db.execSQL("...", new Object[]{});
    }
    db.setTransactionSuccessful();
} catch (Exception e) {

} finally {
    db.endTransaction();
}

使用事务后性能有明显的提升,以批量操作100条为例,由原来的5s优化成了现在的1s。