앱 테마 구현하기에서 제일 중요한 JSON파서와 Bitmap 캐시의 구현입니다. 자세한 설명은 생략하고 코드 위주로 정리합니다.
ThemeJsonParser의 구현입니다. 주요 동작은 ThemeItem.getThemeData().addResouceData()로 리소스 Data를 추가합니다.
public class ThemeJsonParser { // drawable private static final String BACKGROUND = "background"; private static final String IMAGE = "image"; private static final String TEXT_IMAGE = "text_image"; private static final String FLAG = "flag"; private static final String DATA = "data"; private static final String FOREGROUND = "foreground"; // color or color list private static final String BACKGROUND_TINTCOLOR = "background_tintcolor"; private static final String TEXT_COLOR = "text_color"; private static final String IMAGE_TINTCOLOR = "image_tintcolor"; // single color private static final String BACKGROUND_COLOR = "background_color"; private static final String COLOR = "color"; private static final String TEXT_HINT_COLOR = "text_hint_color"; private static final String TEXT_SHADOW_COLOR = "text_shadow_color"; // state public static final String NORMAL = "normal"; public static final String PRESSED = "pressed"; public static final String SELECTED = "selected"; public static final String DISABLED = "disabled"; public static final String CHECKED = "checked"; public static final String PADDING = "padding"; private static final String TILEMODE = "tilemode"; JSONObject root; private boolean printOnce = true; public void setThemeId(int themeId, boolean hasFiles) { root = null; if (!hasFiles) { return; } try { String themeJsonPath = ThemeUtils.getThemeJsonPath(ApplicationKeeper.get(), themeId); String json = FileUtils.readFileContent(themeJsonPath); //TraceUtils.v("[Theme] themeId=%d \n%s", themeId, json); root = new JSONObject(json); } catch (Exception ex) { DebugUtils.notReached(ex); } } public boolean parseThemeItem(ThemeItem themeItem) { if (root == null) { TraceUtils.e("rootJsonObject is null"); return false; } JSONObject itemObject = root.optJSONObject(themeItem.key); if (itemObject != null) { for (int i = 0; i < themeItem.names.length; i++) { JSONObject nameObject = itemObject.optJSONObject(themeItem.names[i]); if (nameObject != null) { parseNameItem(themeItem.getThemeData(i), themeItem.names[i], nameObject); } } return true; } else { TraceUtils.e1("[Theme] item not found=%s", themeItem.key); } return false; } private void parseNameItem(ThemeData themeData, String name, JSONObject nameObject) { parseDrawable(themeData, nameObject, R.id.thk_background_drawable, BACKGROUND); parseDrawable(themeData, nameObject, R.id.thk_image_drawable, IMAGE); parseDrawable(themeData, nameObject, R.id.thk_foreground_drawable, FOREGROUND); parseDrawable(themeData, nameObject, R.id.thk_text_drawable, TEXT_IMAGE); parseColorList(themeData, name, nameObject, R.id.thk_text_color, TEXT_COLOR); parseColorList(themeData, name, nameObject, R.id.thk_image_tint_color, IMAGE_TINTCOLOR); parseColorList(themeData, name, nameObject, R.id.thk_background_tint_color, BACKGROUND_TINTCOLOR); parseColor(themeData, name, nameObject, R.id.thk_background_color, BACKGROUND_COLOR); parseColor(themeData, name, nameObject, R.id.thk_color, COLOR); parseColor(themeData, name, nameObject, R.id.thk_text_hint_color, TEXT_HINT_COLOR); parseColor(themeData, name, nameObject, R.id.thk_text_shadow_color, TEXT_SHADOW_COLOR); if (nameObject.has(DATA)) { themeData.addResourceId(false, R.id.thk_data, nameObject.optInt(DATA)); } } private void parseDrawable(ThemeData themeData, JSONObject nameObject, int resourceType, String jsonKey) { Object object = nameObject.opt(jsonKey); if (object != null) { if (object instanceof JSONObject) { // selector drawable JSONObject jsonObject = (JSONObject) object; HashMap<String, String> stateMap = new HashMap<>(); String filename; filename = jsonObject.optString(NORMAL); if (!TextUtils.isEmpty(filename)) { stateMap.put(NORMAL, filename); } filename = jsonObject.optString(PRESSED); if (!TextUtils.isEmpty(filename)) { stateMap.put(PRESSED, filename); } filename = jsonObject.optString(SELECTED); if (!TextUtils.isEmpty(filename)) { stateMap.put(SELECTED, filename); } filename = jsonObject.optString(DISABLED); if (!TextUtils.isEmpty(filename)) { stateMap.put(DISABLED, filename); } filename = jsonObject.optString(CHECKED); if (!TextUtils.isEmpty(filename)) { stateMap.put(CHECKED, filename); } Rect padding = getPadding(jsonObject.optString(PADDING), jsonKey); int flag = jsonObject.optInt(FLAG); boolean isTileMode = jsonObject.has(TILEMODE); if (!stateMap.isEmpty()) { if (stateMap.size() == 1) { filename = stateMap.values().toArray(new String[1])[0]; themeData.addResourceData(resourceType, new ImageResourceData(resourceType, filename, padding, isTileMode, flag)); } else { themeData.addResourceData(resourceType, new ImageResourceData(resourceType, stateMap, padding, isTileMode, flag)); } } else { TraceUtils.e1("[Theme] background item not found=%s", jsonKey); } } else { String filename = toString(object); if (!TextUtils.isEmpty(filename)) { themeData.addResourceData(resourceType, new ImageResourceData(resourceType, filename, null, false, 0)); } else { TraceUtils.e1("[Theme] background item not found=%s", jsonKey); } } } } private Rect getPadding(String paddingString, String jsonKey) { Rect padding = null; if (!TextUtils.isEmpty(paddingString)) { String[] paddingList = TextUtils.split(paddingString, " "); if (paddingList.length == 1) { int paddingInt = "-1".equals(paddingList[0]) ? -1 : DisplayUtils.toPixel(Float.valueOf(paddingList[0])); padding = new Rect(); padding.left = paddingInt; padding.top = paddingInt; padding.right = paddingInt; padding.bottom = paddingInt; } else if (paddingList.length == 2) { int paddingLeft = "-1".equals(paddingList[0]) ? -1 : DisplayUtils.toPixel(Float.valueOf(paddingList[0])); int paddingTop = "-1".equals(paddingList[1]) ? -1 : DisplayUtils.toPixel(Float.valueOf(paddingList[1])); padding = new Rect(); padding.left = paddingLeft; padding.top = paddingTop; padding.right = paddingLeft; padding.bottom = paddingTop; } else if (paddingList.length == 4) { padding = new Rect(); padding.left = "-1".equals(paddingList[0]) ? -1 : DisplayUtils.toPixel(Float.valueOf(paddingList[0])); padding.top = "-1".equals(paddingList[1]) ? -1 : DisplayUtils.toPixel(Float.valueOf(paddingList[1])); padding.right = "-1".equals(paddingList[2]) ? -1 : DisplayUtils.toPixel(Float.valueOf(paddingList[2])); padding.bottom = "-1".equals(paddingList[3]) ? -1 : DisplayUtils.toPixel(Float.valueOf(paddingList[3])); } else { TraceUtils.e1("[Theme] background item %s > invalid padding value", jsonKey); } } return padding; } private void parseColorList(ThemeData themeData, String name, JSONObject nameObject, int resourceType, String jsonKey) { Object object = nameObject.opt(jsonKey); if (object != null) { if (object instanceof JSONObject) { // color list JSONObject jsonObject = (JSONObject) object; ColorStateList colorStateList = getColorStateList(jsonObject); if (colorStateList != null) { themeData.addResourceData(resourceType, new ColorListResourceData(resourceType, colorStateList)); } else { TraceUtils.e("[Theme] invalid colorList, name=%s colorList=%s", name, jsonKey); } } else { String colorString = toString(object); if (!TextUtils.isEmpty(colorString)) { try { themeData.addResourceId(false, resourceType, Color.parseColor(colorString)); } catch (Exception ignored) { TraceUtils.e("[Theme] invalid color, name=%s key=%s colorString=%s", name, jsonKey, colorString); } } } } } private void parseColor(ThemeData themeData, String name, JSONObject nameObject, int resourceType, String jsonKey) { Object object = nameObject.opt(jsonKey); if (object instanceof String) { String colorString = (String) object; if (!TextUtils.isEmpty(colorString)) { try { themeData.addResourceId(false, resourceType, Color.parseColor(colorString)); } catch (Exception ignored) { TraceUtils.e("[Theme] invalid color > name=%s color=%s", name, jsonKey); } } } else if (object instanceof JSONObject) { if (resourceType == R.id.thk_background_color) { JSONObject jsonObject = (JSONObject) object; HashMap<String, String> stateMap = new HashMap<>(); String colorString; colorString = jsonObject.optString(NORMAL); if (!TextUtils.isEmpty(colorString)) { stateMap.put(NORMAL, colorString); } colorString = jsonObject.optString(PRESSED); if (!TextUtils.isEmpty(colorString)) { stateMap.put(PRESSED, colorString); } colorString = jsonObject.optString(SELECTED); if (!TextUtils.isEmpty(colorString)) { stateMap.put(SELECTED, colorString); } colorString = jsonObject.optString(DISABLED); if (!TextUtils.isEmpty(colorString)) { stateMap.put(DISABLED, colorString); } Rect padding = getPadding(jsonObject.optString(PADDING), jsonKey); if (!stateMap.isEmpty()) { themeData.addResourceData(resourceType, new ImageResourceData(resourceType, stateMap, padding, false, 0)); } } else { TraceUtils.e("[Theme] invalid color list > name=%s color=%s", name, jsonKey); } } } private ColorStateList getColorStateList(JSONObject jsonObject) { String normal = jsonObject.optString(NORMAL, null); String pressed = jsonObject.optString(PRESSED, null); String selected = jsonObject.optString(SELECTED, null); String disabled = jsonObject.optString(DISABLED, null); List<int[]> stateList = new ArrayList<>(); List<Integer> colorList = new ArrayList<>(); Integer color; color = pressed != null ? getColor(pressed) : null; if (color != null) { stateList.add(new int[]{android.R.attr.state_pressed}); colorList.add(color); } color = selected != null ? getColor(selected) : null; if (color != null) { stateList.add(new int[]{android.R.attr.state_selected}); colorList.add(color); } color = disabled != null ? getColor(disabled) : null; if (color != null) { stateList.add(new int[]{-android.R.attr.state_enabled}); colorList.add(color); } color = normal != null ? getColor(normal) : null; if (color != null) { stateList.add(new int[]{}); colorList.add(color); } if (!stateList.isEmpty()) { int[][] stateArray = stateList.toArray(new int[stateList.size()][]); int[] colorArray = new int[colorList.size()]; for (int i = 0; i < colorList.size(); i++) { colorArray[i] = colorList.get(i); } return new ColorStateList(stateArray, colorArray); } else { TraceUtils.e("[Theme] invalid colorList\n%s", jsonObject.toString()); return null; } } String toString(Object value) { if (value instanceof String) { return (String) value; } else if (value != null) { return String.valueOf(value); } return null; } Integer getColor(String colorString) { try { return Color.parseColor(colorString); } catch (Exception ignored) { TraceUtils.e("[Theme] invalid color: %s", colorString); return null; } } }
ImageResourceData의 구현입니다. png를 이용한 단순 Drawable, 상태가 있는 Drawable, 그리고 상태가 있는 color background drawable을 다룹니다.
public class ImageResourceData implements ResourceData { private final int resourceType; private final String filename; private boolean isTileMode; private final HashMap<String, String> stateMap; private Rect padding; private final int flag; private static final int EMPTY = 1; private static final int LEFT = 2; private static final int TOP = 4; private static final int RIGHT = 8; private static final int BOTTOM = 16; private boolean hasFlag(int flag) { return (this.flag & flag) > 0; } public ImageResourceData(int resourceType, String filename, Rect padding, boolean isTileMode, int flag) { this.resourceType = resourceType; this.filename = filename; this.stateMap = null; this.padding = padding; this.isTileMode = isTileMode; this.flag = flag; } public ImageResourceData(int resourceType, HashMap<String, String> stateMap, Rect padding, boolean isTileMode, int flag) { this.resourceType = resourceType; this.filename = null; this.stateMap = stateMap; this.padding = padding; this.isTileMode = isTileMode; this.flag = flag; } @Override public boolean applyTheme(Context context, View view, ThemeDrawableCache cache) { Drawable drawable = getDrawable(cache); if (drawable != null) { if (resourceType == R.id.thk_background_drawable || resourceType == R.id.thk_background_color) { if (view instanceof CheckBox) { ((CheckBox) view).setButtonDrawable(drawable); } else { view.setBackgroundDrawable(drawable); } maybeSetPadding(view); return true; } else if (resourceType == R.id.thk_image_drawable) { if (view instanceof ImageView) { ((ImageView) view).setImageDrawable(drawable); } else { view.setBackgroundDrawable(drawable); } maybeSetPadding(view); return true; } else if (resourceType == R.id.thk_foreground_drawable) { if (view instanceof FrameLayout) { ((FrameLayout) view).setForeground(drawable); maybeSetPadding(view); return true; } } else if (resourceType == R.id.thk_text_drawable) { if (view instanceof TextView) { TextView textView = ((TextView) view); if (hasFlag(EMPTY)) { textView.setText(""); } textView.setCompoundDrawablesWithIntrinsicBounds(hasFlag(LEFT) ? drawable : null, hasFlag(TOP) ? drawable : null, hasFlag(RIGHT) ? drawable : null, hasFlag(BOTTOM) ? drawable : null); if (padding != null) { textView.setCompoundDrawablePadding(padding.left); } } } } return false; } private void maybeSetPadding(View view) { if (padding != null) { int left = view.getPaddingLeft(); int top = view.getPaddingTop(); int right = view.getPaddingRight(); int bottom = view.getPaddingBottom(); view.setPadding(padding.left == -1 ? left : padding.left, padding.top == -1 ? top : padding.top, padding.right == -1 ? right : padding.right, padding.bottom == -1 ? bottom : padding.bottom); } } public Drawable getDrawable(ThemeDrawableCache cache) { Drawable drawable = cache.getCachedDrawable(this); if (drawable == null) { if (filename != null) { drawable = cache.getDrawable(filename, isTileMode); if (drawable != null) { cache.putCachedDrawable(this, drawable); } } else if (stateMap != null) { if (resourceType == R.id.thk_background_color) { drawable = getColorListDrawable(); if (drawable != null) { cache.putCachedDrawable(this, drawable); } } else { drawable = getStateListDrawable(cache); if (drawable != null) { cache.putCachedDrawable(this, drawable); } } } } return drawable; } private Drawable getColorListDrawable() { StateListDrawable stateListDrawable = new StateListDrawable(); String colorString; colorString = stateMap.get(ThemeJsonParser.PRESSED); if (!TextUtils.isEmpty(colorString)) { Integer color = getColor(colorString); if (color != null) { stateListDrawable.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(color)); } } colorString = stateMap.get(ThemeJsonParser.SELECTED); if (!TextUtils.isEmpty(colorString)) { Integer color = getColor(colorString); if (color != null) { stateListDrawable.addState(new int[]{android.R.attr.state_selected}, new ColorDrawable(color)); } } colorString = stateMap.get(ThemeJsonParser.DISABLED); if (!TextUtils.isEmpty(colorString)) { Integer color = getColor(colorString); if (color != null) { stateListDrawable.addState(new int[]{-android.R.attr.state_enabled}, new ColorDrawable(color)); } } colorString = stateMap.get(ThemeJsonParser.NORMAL); if (!TextUtils.isEmpty(colorString)) { Integer color = getColor(colorString); if (color != null) { stateListDrawable.addState(new int[]{}, new ColorDrawable(color)); } } return stateListDrawable; } Integer getColor(String colorString) { try { return Color.parseColor(colorString); } catch (Exception ignored) { TraceUtils.e("Theme invalid color: %s", colorString); return null; } } private StateListDrawable getStateListDrawable(ThemeDrawableCache cache) { StateListDrawable stateListDrawable = new StateListDrawable(); String imageName; imageName = stateMap.get(ThemeJsonParser.PRESSED); if (imageName != null) { Drawable imageDrawable = cache.getDrawable(imageName, isTileMode); if (imageDrawable != null) { stateListDrawable.addState(new int[]{android.R.attr.state_pressed}, imageDrawable); } } imageName = stateMap.get(ThemeJsonParser.SELECTED); if (imageName != null) { Drawable imageDrawable = cache.getDrawable(imageName, isTileMode); if (imageDrawable != null) { stateListDrawable.addState(new int[]{android.R.attr.state_selected}, imageDrawable); } } imageName = stateMap.get(ThemeJsonParser.DISABLED); if (imageName != null) { Drawable imageDrawable = cache.getDrawable(imageName, isTileMode); if (imageDrawable != null) { stateListDrawable.addState(new int[]{-android.R.attr.state_enabled}, imageDrawable); } } boolean hasChecked = false; imageName = stateMap.get(ThemeJsonParser.CHECKED); if (imageName != null) { Drawable imageDrawable = cache.getDrawable(imageName, isTileMode); if (imageDrawable != null) { stateListDrawable.addState(new int[]{android.R.attr.state_checked}, imageDrawable); hasChecked = true; } } imageName = stateMap.get(ThemeJsonParser.NORMAL); if (imageName != null) { Drawable imageDrawable = cache.getDrawable(imageName, isTileMode); if (imageDrawable != null) { if (hasChecked) { stateListDrawable.addState(new int[]{-android.R.attr.state_checked}, imageDrawable); } else { stateListDrawable.addState(new int[]{}, imageDrawable); } } } return stateListDrawable; } }
ColorListResourceData의 구현입니다.
public class ColorListResourceData implements ResourceData { private final int resourceType; private final ColorStateList colorStateList; public ColorListResourceData(int resourceType, ColorStateList colorStateList) { this.resourceType = resourceType; this.colorStateList = colorStateList; } @Override public boolean applyTheme(Context context, View view, ThemeDrawableCache cache) { if (resourceType == R.id.thk_image_tint_color || resourceType == R.id.thk_image_tint_color_list) { if (view instanceof ImageView) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { ((ImageView) view).setImageTintList(colorStateList); } else { ((ImageView) view).setColorFilter(colorStateList.getDefaultColor(), PorterDuff.Mode.SRC_ATOP); } return true; } } else if (resourceType == R.id.thk_text_color || resourceType == R.id.thk_text_color_list) { if (view instanceof TextView) { ((TextView) view).setTextColor(colorStateList); return true; } } else if (resourceType == R.id.thk_background_tint_color || resourceType == R.id.thk_background_tint_color_list) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { view.setBackgroundTintMode(PorterDuff.Mode.SRC_ATOP); view.setBackgroundTintList(colorStateList); } else { Drawable background = view.getBackground(); if (background != null) { if (background instanceof ColorDrawable) { ((ColorDrawable) background).setColor(colorStateList.getDefaultColor()); } else { background.mutate().setColorFilter(colorStateList.getDefaultColor(), PorterDuff.Mode.SRC_ATOP); } } } } else { TraceUtils.e("[Theme] unsupported resourceType"); } return false; } public ColorStateList getColorStateList() { return colorStateList; } }
ThemeDrawableCache의 구현입니다. png 파일에서 Bitmap을 로드하여 Lru캐시로 관리하고, drawable 캐시도 구현합니다.
public class ThemeDrawableCache { private final LruCache<String, Pair<Bitmap, Rect>> bitmapCache = new LruCache<>(30); private final LruCache<ImageResourceData, Drawable.ConstantState> drawableStateCache = new LruCache<>(30); private final Context context = ApplicationKeeper.get(); private File dir; public void setThemeId(int themeId) { this.dir = new File(ThemeUtils.getThemeDirectoryPath(context, themeId)); this.bitmapCache.evictAll(); this.drawableStateCache.evictAll(); } public Drawable getCachedDrawable(ImageResourceData imageResourceData) { Drawable.ConstantState constantState = drawableStateCache.get(imageResourceData); if (constantState != null) { return constantState.newDrawable(context.getResources()); } else { return null; } } public void putCachedDrawable(ImageResourceData imageResourceData, Drawable drawable) { Drawable.ConstantState constantState = drawable.getConstantState(); if (constantState != null) { drawableStateCache.put(imageResourceData, constantState); } } public Drawable getDrawable(String filename, boolean isTileMode) { Pair<Bitmap, Rect> bitmapInfo = getBitmapInfo(filename); if (bitmapInfo != null) { Bitmap bitmap = bitmapInfo.first; Rect bitmapPadding = bitmapInfo.second; Drawable drawable = null; if (filename.endsWith(".9.png")) { byte[] chunk = bitmap.getNinePatchChunk(); if (NinePatch.isNinePatchChunk(chunk)) { drawable = new NinePatchDrawable(context.getResources(), bitmap, chunk, bitmapPadding, null); } else { TraceUtils.e("Theme invalid 9patch file: %s", filename); } } if (drawable == null) { BitmapDrawable bitmapDrawable = new BitmapDrawable(context.getResources(), bitmap); if (isTileMode) { bitmapDrawable.setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT); } drawable = bitmapDrawable; } return drawable; } else { return null; } } private Pair<Bitmap, Rect> getBitmapInfo(String filename) { Pair<Bitmap, Rect> bitmapInfo = bitmapCache.get(filename); if (bitmapInfo != null) { return bitmapInfo; } final int targetDensity = DisplayMetrics.DENSITY_XHIGH; final int screenDensity = context.getResources().getDisplayMetrics().densityDpi; final BitmapFactory.Options options; options = new BitmapFactory.Options(); options.inDensity = targetDensity; options.inScreenDensity = screenDensity; options.inTargetDensity = screenDensity; options.inDither = true; options.inScaled = true; InputStream fis = null; File file = new File(dir, filename); Rect bitmapPadding = new Rect(); Bitmap bitmap = null; try { fis = new FileInputStream(file); bitmap = BitmapFactory.decodeStream(fis, bitmapPadding, options); } catch (FileNotFoundException e) { DebugUtils.notReached(e); } finally { try { if (fis != null) { fis.close(); } } catch (IOException ie) { DebugUtils.notReached(ie); } } if (bitmap != null) { bitmapInfo = new Pair<>(bitmap, bitmapPadding); bitmapCache.put(filename, bitmapInfo); return bitmapInfo; } else { return null; } } }
그리고 마지막으로 .9.png 파일은 aapt로 컴파일해야합니다. raw폴더의 모든 png를 컴파일해서 theme에 복사하는 스크립트입니다.
del /Q .\theme\*.png aapt.exe c -S raw -C theme if exist test_theme.zip del test_theme.zip cd theme "c:\Program Files\7-Zip\7z.exe" a ..\test_theme.zip *.* cd ..