天天看点

Android 自定义锁屏的实现

最近公司无事,所以找点事干。刚好在研究view和viewgroup这部分的源码,也尝试重写一些view和viewgroup加深理解。看到网上有人写九宫格的手势锁屏,就自己试了试,坐下来感觉难度不大,倒是有很多细节上的东西,需要记录一下,而且过程中也确实学到了不少,进步了不少。

一. 思路

看到网上的同仁,大体是2种方式,一种是直接重写一个view,然后绘制所有的东西,另外一种是重写view绘制圆点,再重写一个viewgroup作为圆点的容器,当然貌似还有其他的实现方式,我没太注意。在这里我采用第二种实现方式。

二. 效果图

随便弄了个下图,感觉看起来顺眼,有时间还得好好再加工一下:

Android 自定义锁屏的实现
Android 自定义锁屏的实现

三. 重写view

DotView,其实这个view重写的逻辑很简单,就是设置好画笔,在onDraw()里面画圆圈就行了,代码如下:

protected void onDraw(Canvas canvas) {
		mWidth = getWidth();
		mHeight = getHeight();

		centerX = mWidth / 2;
		centerY = mHeight / 2;
		/**radius of the outter circle */
		mRadius = mWidth > mHeight ? centerY: centerX;
		innerRadius = (float) (mRadius * 0.2);
		outterRadius = (float) (mRadius * 0.5);
		canvas.save();
		
		switch (mState) {
		case STATE_NORMAL:
			drawDot(canvas);
			break;
		case STATE_TOUCHED:
			canvas.restore();
			mPaint.setColor(CORLOR_SELECTED);
			drawCircle(canvas);
			break;
		case STATE_ERROR:
			canvas.restore();
			mPaint.setColor(CORLOR_ERROR);
			drawCircle(canvas);
			break;
		}
	}
           

STATE_NORMAL,STATE_TOUCHED和STATE_ERROR这3个常量,分别代表圆圈的正常状态,被触摸或点中后的状态,解锁失败以后要呈现的状态;

/**
	 * draw the smallest inner dot
	 * @param canvas  draw on which
	 */
	private void drawDot(Canvas canvas) {	
		canvas.restore();
		mPaint.setStyle(Paint.Style.STROKE);
		mPaint.setStrokeWidth(1);
		mPaint.setColor(COLOR_NORMAL_BACK);	
		canvas.drawCircle(centerX, centerY, outterRadius, mPaint);
		
		mPaint.setStyle(Paint.Style.FILL);
		mPaint.setColor(WHITE);
		canvas.drawCircle(centerX, centerY, innerRadius, mPaint);
	}
	
	/**
	 * draw two circles when selected
	 * @param canvas
	 */
	private void drawCircle(Canvas canvas) {
		mPaint.setStyle(Paint.Style.FILL);
		canvas.drawCircle(centerX, centerY, innerRadius, mPaint);
		
		mPaint.setStyle(Paint.Style.STROKE);
		mPaint.setStrokeWidth(innerRadius);
		canvas.drawCircle(centerX, centerY, outterRadius, mPaint);
	}
           

这2个方法是画内圈的实心圆点,和外圈的空心圆,逻辑也十分简单;

基本上主要代码就是这些了,所以说很简单;

二. 重写Viewgroup

网上很多实现方式都是重写的相对布局或者线性布局,好处就是不用写onLayout()了,我自己重写了viewgroup,不过布局也不难,因为我的锁屏是写死了九宫格,不像其他的实现,是可以任意宫的;

ScreenLockView的主要代码和逻辑就相对多一些:

public ScreenLockView(Context context, AttributeSet attrs, int defStyle) {
		super(context, attrs, defStyle);
		mSeletedViews = new ArrayList<Integer>();
		mDotViews = new DotView[TOTAL_DOTS];
		unlockPath = new Path();
		
		mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
		mPaint.setColor(DotView.CORLOR_SELECTED);
		mPaint.setStyle(Paint.Style.STROKE);
		mPaint.setStrokeCap(Paint.Cap.ROUND);
		mPaint.setStrokeJoin(Paint.Join.ROUND);
		mPaint.setAlpha(150);
	}
           

我在学习过程中,看到2个没用过的方法:mPaint.setStrokeCap(Paint.Cap.ROUND)和mPaint.setStrokeJoin(Paint.Join.ROUND),在网上查找,居然得出很多不同的答案,自己试验了下,基本上是使绘制的线条比较平滑,还有转接的地方比较自然。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		super.onMeasure(widthMeasureSpec, heightMeasureSpec);
		int width = MeasureSpec.getSize(widthMeasureSpec);
		int height = MeasureSpec.getSize(heightMeasureSpec);
		
		if (mDotViews != null) {
			/**
			 * layout the dotviews :3 rows and 3 columns
			 */
			mViewWidth = width / 3;
			mViewHeight = height / 3;
			/**
			 * add to the parent
			 */
			ViewGroup.LayoutParams params;
			params = new LayoutParams(mViewWidth, mViewHeight);
			for (int i = 0 ; i < TOTAL_DOTS ; i ++) {
				mDotViews[i] = new DotView(getContext());
				mDotViews[i].setId(i);
				addView(mDotViews[i], params);
			}
		}
		setMeasuredDimension(width, height);
	}
           

在onMeasure()里面把9个圆点视图添加到父容器中,尺寸大小也比较好计算;

protected void onLayout(boolean changed, int l, int t, int r, int b) {
		int row = 0;
		int column = 0;
		for (int i = 0 ; i < 9 ; i ++) {
			/**
			 * the layout like this:
			 *    row |   0   1   2
			 * --------------------
			 * column |      
			 *    0   |   0   1   2
			 *    1   |   3   4   5
			 *    2   |   6   7   8
			 */
			row = i / 3;
			column = i % 3;
			getChildAt(i).layout(column * mViewWidth, row * mViewHeight
					, (column + 1) * mViewWidth, (row + 1) * mViewHeight);
		}
	}
           

至于布局的排列,逻辑上也很简单,按照我的排列方法,id除以3是行号,id取余3是列号,然后计算好位置也很简单;

另外发现一个问题,l,t,是实际使用时,可能是非0的值,因为容器内可能有其他控件占据位置,但是在布局时,从逻辑上要把他们当成坐标轴的x,y去看待,即要认为我们是在圆点为(l,t)的坐标轴内布局,否则视图就会发生偏移。这也是我遇到问题之后,研究了不少时间才解决的。

public boolean onTouchEvent(MotionEvent event) {
		int action = event.getAction();
		float x = event.getX();
		float y = event.getY();
		/** calculate the location according to the touching point*/
		int id = calculateIdByXY(x, y);
		if (MotionEvent.ACTION_DOWN == action) {
			/**
			 * if touching a valid point
			 */
			if (!unlockPath.isEmpty()) {
				clear();
			} else {
				if (INVALID_REGION != id) {
					isUnlocking = true;
					DotView view = (DotView) findViewById(id);
					view.setState(DotView.STATE_TOUCHED);
					mSeletedViews.add(id);
					
					/** calculate the center x&y according to view id*/
					float centerX = (float) ((id % 3 + 0.5) * mViewWidth);
					float centerY = (float) ((id / 3 + 0.5) * mViewHeight);
					unlockPath.moveTo(centerX,centerY);
				}
			}
		} else if (MotionEvent.ACTION_MOVE == action) {
			if (isUnlocking) {
				if (INVALID_REGION != id) {
					DotView view = (DotView) findViewById(id);
					view.setState(DotView.STATE_TOUCHED);
					
					if (!mSeletedViews.contains(id)) {
						mSeletedViews.add(id);
					}
				} 
				setPath();
				unlockPath.lineTo(x, y);
			}
		} else if (MotionEvent.ACTION_UP == action) {
			if (isUnlocking) {
				isUnlocking = false;
				setPath();
				mListener.onComplete(mSeletedViews);
			}
		}
		invalidate();
		return true;
	}
	
           

onTouchEvent()的逻辑相对复杂一些,不过分为按下,移动和抬起来看,调理仍很清晰;

-如果触摸到圆点有效范围,就计算出圆点圆心并设为path的起点;

-如果在移动过程中,就按照触摸点的位置,绘制路径;

-一旦抬起,就结束绘制,刷新界面;

setPath()是将已经触摸过的圆点,依次绘制路线连接起来;

protected void dispatchDraw(Canvas canvas) {
		super.dispatchDraw(canvas);
		int width = (int) (mViewHeight > mViewWidth ? (mViewWidth * 0.5 * 0.25) : (mViewHeight * 0.5 * 0.25));
		mPaint.setStrokeWidth(width);
		
		canvas.drawPath(unlockPath, mPaint);
	}
           

重写dispatchDraw()来绘制路线;

/**
	 * if the wrong pw is inputed,draw in error color and clear all after one second
	 */
	public void error(){
		for (int i = 0 ; i < mSeletedViews.size() ; i ++) {	
			((DotView) findViewById(mSeletedViews.get(i))).setState(DotView.STATE_ERROR);
		}
		mPaint.setColor(DotView.COLOR_ERROR);
		mPaint.setAlpha(150);
		invalidate();
		new Thread(new Runnable(){

			@Override
			public void run() {
				try {
					Thread.sleep(1000);
					mHandler.sendEmptyMessage(0);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}		
			}	
		}).start();
	}
           

error()方法被调用,当用户输入错误密码的时候,它将所有选中圆点和路径显示为红色,并在1秒后,全部清除;

/**
	 * calculate if a touching point is on an available position
	 * 
	 * @param x
	 * @param y
	 * @return id -the view's id if it is , -1 if not
	 */
	private int calculateIdByXY(float x , float y) {
		int column = (int) (x / mViewWidth);
		int row = (int) (y / mViewHeight);
		float radius = mViewWidth > mViewHeight ? (mViewHeight / 2) : (mViewWidth / 2);
		radius = (float) (radius * 0.4);
		float xBottomBorder = (float) ((column + 0.5) * mViewWidth - radius);
		float xTopBorder = xBottomBorder + 2 * radius;
		float yBottomBorder = (float) ((row + 0.5) * mViewHeight - radius);
		float yTopBorder = yBottomBorder + 2 * radius;
		
		if ( x >= xBottomBorder && x <= xTopBorder &&
				y >= yBottomBorder && y <= yTopBorder) {
			return row * 3 + column;
		}
		return INVALID_REGION;
	}
           

calculateIdByXY()通过xy坐标计算触摸点是否在圆点有效位置,如果是就返回该view的id;

行号和列号,在一开始很容易就可以计算出来,有个行列号,下来的工作其实很简单了,Row*3+column就是view的id号了,其他的计算是在确定是否属于有效区域;

以上基本全是是关于重写方面的工作了,接下来的知识点全是关于锁屏的了;

四. 锁屏逻辑实现

Android 自定义锁屏的实现
Android 自定义锁屏的实现

这张图显示了主要的逻辑关系:

MainActivity:设置密码的界面,在应用安装之后启动,可以设置和修改密码;它包含了一个ScreenLockView,当应用第一次安装后,设置密码成功,会启动LockService服务;

LockActivity:锁屏的真正界面;

LockService:服务开机启动,接收屏蔽亮灭的广播,来开启LockActivity;

BootCompletedReceiver:接收开机完成广播,并启动LockService;

1. 密码存储:

在设置密码界面,将密码存储在SharedPreferences中;

2. 保持服务常驻

通过网络查阅和sdk阅读,我总共使用了3种方式来防止服务被杀死,以及被杀死后自动重启;

Android 自定义锁屏的实现

在配置文件中,设置服务的优先级为最高;

Android 自定义锁屏的实现

在onStartCommand()方法中,使用startForeground(0, null)方法将服务设置为前台服务,方法参数用来设置要显示的通知,这里不显示,所以设置为0,并且在onDestroy()方法中,使用stopForeground(true);

在onStartCommand()方法中,保存服务启动的intent,然后在onDestroy中重新启动:

Android 自定义锁屏的实现
Android 自定义锁屏的实现

3. 相关广播

一个外部广播接收器在开机完成后启动服务,一个内部广播接收器接收屏幕亮灭广播,从而启动锁屏界面;

4. 屏蔽系统锁屏

Android 自定义锁屏的实现
Android 自定义锁屏的实现

这是目前采用的方法,也是网上流传很多的方法,是管用的,需要在配置文件中增加使用权限android.permission.RECEIVE_BOOT_COMPLETED;

然而我在sdk当中看到,这种方法已经被弃用,官方有推荐的新方法,并且网上也说这种方法会在新版本失效,我在我的4.0的手机上测试时,仍然是有效的;

官方推出的新方法是:

getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);

getWindow().addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);

但是我在LockActivity中使用这种方法,并没有产生作用,所以我只好采用了旧办法;

5. 屏蔽状态栏

我目前的做法是使LockActivity全屏显示了,但是很多人说这样不好,得让状态栏能显示,但是又不能下拉,这个我暂且留着研究;

续:

在网上找到一个简单,效果还不错的实现屏蔽的方法:

Android 自定义锁屏的实现

在锁屏界面重写此方法。

6.屏蔽home键

网上流传的屏蔽home键的方法有好几个版本,包括旧版本中有效,而新版本失效的方法,我只提供最新最近的方法,应该是向下兼容的:

private static final int FLAG_HOMEKEY_DISPATCHED = 0x80000000;

getWindow().setFlags(FLAG_HOMEKEY_DISPATCHED, FLAG_HOMEKEY_DISPATCHED);

而据说android4.0+已经无法屏蔽home键,我未亲测,关于这一点的处理,可以参照以下博客:(Android锁屏页实现原理及技术要点);

7.屏蔽返回键,菜单键,音量键

Android 自定义锁屏的实现
Android 自定义锁屏的实现

但是这样的问题是把开关键也屏蔽了,而系统锁屏,是没有屏蔽开关键的,这个问题我还得好好研究一下,有了结果再更新。

附上完整工程:http://download.csdn.net/detail/free092875/7614735